master
lion 2 months ago
parent 190f56d78f
commit 8e29b69558

@ -47,6 +47,18 @@ export async function getAdminApplication(
return data as AdminApplicationDetail
}
export async function updateAdminApplicationReviewEligible(
competitionId: number,
applicationId: number,
reviewEligible: boolean,
): Promise<{ id: number; review_eligible: boolean }> {
const { data } = await adminHttp.patch<{ id: number; review_eligible: boolean }>(
`/competitions/${competitionId}/applications/${applicationId}/review-eligible`,
{ review_eligible: reviewEligible },
)
return data
}
export async function impersonateAdminApplication(
competitionId: number,
applicationId: number,

@ -147,6 +147,8 @@ export interface AdminApplicationListParams {
signup_channel_id?: number
public_source_channel_id?: number
review_result?: 'passed' | 'rejected' | 'pending' | ''
/** true/false;不传则不过滤 */
review_eligible?: boolean
sort_by?: 'team_avg' | 'submitted_at'
sort_dir?: 'asc' | 'desc'
}
@ -195,6 +197,8 @@ export interface AdminApplicationRow {
review_result: 'passed' | 'rejected' | null
review_result_note: string | null
review_result_at: string | null
/** 是否参与评审;否时不出现在评审端 */
review_eligible: boolean
degree?: string
location_label?: string
public_source_channel: { id: number; source_code: string; source_name: string } | null
@ -497,5 +501,11 @@ export interface ManageReportData {
track: string
items: Array<{ rank: number; name: string; avg: number }>
}>
summary: { total: number; passed: number; rejected: number; pending_review: number }
summary: {
total: number
passed: number
rejected: number
pending_review: number
reviewed: number
}
}

@ -21,9 +21,16 @@ export interface PortalApplicationDetailLike {
submitted_at?: string | null
}
defineProps<{
detail: PortalApplicationDetailLike
}>()
withDefaults(
defineProps<{
detail: PortalApplicationDetailLike
/** 评审端等场景可隐藏推荐方 */
showRecommend?: boolean
}>(),
{
showRecommend: true,
},
)
</script>
<template>
@ -74,7 +81,7 @@ defineProps<{
<label class="form-label">主题赛道</label>
<input class="form-control" type="text" :value="detail.track_title || detail.track_code || '—'" disabled />
</div>
<div class="col-md-4">
<div v-if="showRecommend" class="col-md-4">
<label class="form-label">推荐方</label>
<input class="form-control" type="text" :value="detail.recommend || '—'" disabled />
</div>

@ -89,7 +89,7 @@ const metricCards = computed(() => {
{ label: '报名总数', value: s?.applications_total ?? 0, hint: '含草稿和已提交' },
{ label: '已提交', value: s?.applications_submitted ?? 0, hint: '可进入评审范围' },
{ label: '草稿', value: s?.applications_draft ?? 0, hint: '选手尚未正式提交' },
{ label: '待评项目', value: s?.applications_pending_review ?? 0, hint: '已提交但暂无评分' },
{ label: '待评项目', value: s?.applications_pending_review ?? 0, hint: '参与评审且暂无评分' },
{ label: '已评项目', value: s?.applications_reviewed ?? 0, hint: '至少已有一条评分' },
{ label: '评审记录', value: s?.review_scores_total ?? 0, hint: '累计提交评分数' },
]

@ -29,6 +29,7 @@ import {
getAdminApplication,
impersonateAdminApplication,
listAdminApplications,
updateAdminApplicationReviewEligible,
type AdminApplicationExportMode,
} from '../../../api/admin/applications'
import { useAdminAuthStore } from '../../../stores/adminAuth'
@ -136,9 +137,12 @@ const applicationFilters = ref({
keyword: '',
status: 'submitted',
track: '',
review_eligible: undefined as boolean | undefined,
signup_channel_id: undefined as number | undefined,
public_source_channel_id: undefined as number | undefined,
})
const reviewEligibleSavingIds = ref<number[]>([])
const detailReviewEligibleSaving = ref(false)
const trackDialogVisible = ref(false)
const trackEditingId = ref<number | null>(null)
@ -308,7 +312,11 @@ function settingsWithSuccessNotice(): Record<string, unknown> | null {
user_mobiles: mobiles,
}
} else {
delete settings.post_close_file_edit
// 显式写入关闭状态,避免仅 delete 后服务端 validated 丢 settings 时库内旧白名单残留
settings.post_close_file_edit = {
enabled: false,
user_mobiles: [],
}
}
return Object.keys(settings).length > 0 ? settings : null
@ -868,31 +876,6 @@ function formatScore(n: number | string | null | undefined): string {
return v.toFixed(2).replace(/\.?0+$/, '')
}
function formatReviewScoreItems(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(';')
}
return ''
}
function formatFileSize(size: number | null | undefined): string {
if (!size || size <= 0) return '—'
if (size < 1024) return `${size}B`
@ -942,6 +925,7 @@ async function refreshApplications(page = applicationPager.value.page) {
keyword: applicationFilters.value.keyword.trim() || undefined,
status: applicationFilters.value.status || undefined,
track: applicationFilters.value.track || undefined,
review_eligible: applicationFilters.value.review_eligible,
signup_channel_id: applicationFilters.value.signup_channel_id || undefined,
public_source_channel_id: applicationFilters.value.public_source_channel_id || undefined,
})
@ -964,12 +948,53 @@ function resetApplicationFilters() {
keyword: '',
status: 'submitted',
track: '',
review_eligible: undefined,
signup_channel_id: undefined,
public_source_channel_id: undefined,
}
void refreshApplications(1)
}
async function setApplicationReviewEligible(row: AdminApplicationRow, next: boolean) {
const cid = competitionId.value
if (!cid) return
const prev = row.review_eligible !== false
if (next === prev) return
reviewEligibleSavingIds.value = [...reviewEligibleSavingIds.value, row.id]
try {
const res = await updateAdminApplicationReviewEligible(cid, row.id, next)
row.review_eligible = res.review_eligible
if (applicationDetail.value?.id === row.id) {
applicationDetail.value.review_eligible = res.review_eligible
}
ElMessage.success(next ? '已设为参与评审' : '已设为不参与评审')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '更新失败')
} finally {
reviewEligibleSavingIds.value = reviewEligibleSavingIds.value.filter((id) => id !== row.id)
}
}
async function setDetailReviewEligible(next: boolean) {
const cid = competitionId.value
const detail = applicationDetail.value
if (!cid || !detail) return
const prev = detail.review_eligible !== false
if (next === prev) return
detailReviewEligibleSaving.value = true
try {
const res = await updateAdminApplicationReviewEligible(cid, detail.id, next)
detail.review_eligible = res.review_eligible
const listRow = applications.value.find((r) => r.id === detail.id)
if (listRow) listRow.review_eligible = res.review_eligible
ElMessage.success(next ? '已设为参与评审' : '已设为不参与评审')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '更新失败')
} finally {
detailReviewEligibleSaving.value = false
}
}
async function exportApplications(mode: AdminApplicationExportMode) {
const cid = competitionId.value
if (!cid) return
@ -987,6 +1012,7 @@ async function exportApplications(mode: AdminApplicationExportMode) {
keyword: applicationFilters.value.keyword.trim() || undefined,
status: applicationFilters.value.status || undefined,
track: applicationFilters.value.track || undefined,
review_eligible: applicationFilters.value.review_eligible,
signup_channel_id: applicationFilters.value.signup_channel_id || undefined,
public_source_channel_id: applicationFilters.value.public_source_channel_id || undefined,
application_ids:
@ -1740,6 +1766,16 @@ onMounted(() => {
:value="track.track_code"
/>
</el-select>
<el-select
v-model="applicationFilters.review_eligible"
placeholder="是否参与评审"
clearable
teleported
class="application-filter-select"
>
<el-option label="参与评审" :value="true" />
<el-option label="不参与评审" :value="false" />
</el-select>
<el-select
v-model="applicationFilters.signup_channel_id"
placeholder="报名渠道"
@ -1875,6 +1911,18 @@ onMounted(() => {
</span>
</template>
</el-table-column>
<el-table-column label="是否参与评审" width="130" align="center">
<template #default="{ row }">
<el-switch
:model-value="row.review_eligible !== false"
:loading="reviewEligibleSavingIds.includes(row.id)"
inline-prompt
active-text="是"
inactive-text="否"
@change="(v: string | number | boolean) => void setApplicationReviewEligible(row, v === true)"
/>
</template>
</el-table-column>
<el-table-column label="提交时间" min-width="150">
<template #default="{ row }">{{ formatDateTime(row.submitted_at) }}</template>
</el-table-column>
@ -2171,6 +2219,17 @@ onMounted(() => {
<el-descriptions-item label="推荐方" :span="2">
{{ applicationDetail.recommend || '—' }}
</el-descriptions-item>
<el-descriptions-item label="是否参与评审" :span="2">
<el-switch
:model-value="applicationDetail.review_eligible !== false"
:loading="detailReviewEligibleSaving"
inline-prompt
active-text="是"
inactive-text="否"
@change="(v: string | number | boolean) => void setDetailReviewEligible(v === true)"
/>
<span class="cell-muted ms-2">否:不出现在评审端</span>
</el-descriptions-item>
</el-descriptions>
<h3 class="section-title">项目简介</h3>
@ -2209,11 +2268,6 @@ onMounted(() => {
<el-table-column label="项目得分" width="100">
<template #default="{ row }">{{ formatScore(row.line_total) }}</template>
</el-table-column>
<el-table-column label="分项明细" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
{{ formatReviewScoreItems(row.payload_json) || row.comment || '—' }}
</template>
</el-table-column>
<el-table-column label="更新时间" min-width="160">
<template #default="{ row }">{{ formatDateTime(row.updated_at) }}</template>
</el-table-column>

@ -40,7 +40,7 @@ onMounted(() => void load())
<template v-else-if="report">
<div class="row g-3 mb-1">
<div v-for="(item, idx) in kpis" :key="idx" class="col-12 col-md-6 col-lg-3">
<div v-for="(item, idx) in kpis" :key="idx" class="col-12 col-sm-6 col-xl">
<div class="report-kpi-card">
<div class="report-kpi-label">{{ item.label }}</div>
<div class="report-kpi-value">{{ item.value }}</div>

@ -61,36 +61,6 @@ function formatScore(v: number | null | undefined): string {
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
@ -178,6 +148,14 @@ watch([competitionId, appId], () => void load(), { immediate: true })
disabled
/>
</div>
<div class="col-md-4">
<label class="form-label">是否参与评审</label>
<input
class="form-control"
:value="detail.review_eligible !== false ? '是' : '否'"
disabled
/>
</div>
</div>
<div class="row g-3 mt-1">
@ -225,12 +203,11 @@ watch([competitionId, appId], () => void load(), { immediate: true })
<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>
<thead><tr><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>

@ -78,6 +78,7 @@ const filters = reactive({
track: '',
entry_group: DEFAULT_ENTRY_GROUP as string,
review_result: '' as '' | 'passed' | 'rejected' | 'pending',
review_eligible: '' as '' | '1' | '0',
signup_channel_id: '' as number | '',
public_source_channel_id: '' as number | '',
})
@ -113,6 +114,8 @@ function buildListParams(page: number): AdminApplicationListParams {
if (filters.review_result === 'passed' || filters.review_result === 'rejected' || filters.review_result === 'pending') {
params.review_result = filters.review_result
}
if (filters.review_eligible === '1') params.review_eligible = true
if (filters.review_eligible === '0') params.review_eligible = false
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)
@ -271,6 +274,7 @@ function resetFilters() {
filters.track = ''
filters.entry_group = DEFAULT_ENTRY_GROUP
filters.review_result = ''
filters.review_eligible = ''
filters.signup_channel_id = ''
filters.public_source_channel_id = ''
auditSelectedIds.value = []
@ -402,6 +406,13 @@ watch(competitionId, async (cid) => {
<option value="pending">待评定</option>
</select>
</div>
<div class="col-md-2">
<select v-model="filters.review_eligible" class="form-select">
<option value="">全部(是否参与评审)</option>
<option value="1">参与评审</option>
<option value="0">不参与评审</option>
</select>
</div>
<div class="col-md-2">
<select v-model="filters.signup_channel_id" class="form-select">
<option value="">全部报名渠道</option>
@ -521,15 +532,16 @@ watch(competitionId, async (cid) => {
</button>
</th>
<th class="portal-sticky-right-2">状态</th>
<th>是否参与评审</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>
<td colspan="22" 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>
<td colspan="22" 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">
@ -571,6 +583,7 @@ watch(competitionId, async (cid) => {
>未通过</span>
<span v-else>-</span>
</td>
<td>{{ row.review_eligible !== false ? '是' : '否' }}</td>
<td class="portal-sticky-right-1">
<button type="button" class="btn btn-sm btn-outline-primary" @click="openDetail(row)">查看</button>
</td>

@ -119,46 +119,53 @@ const loadError = ref('')
const pledgeSignedDateCn = computed(() => formatPledgeCnDate(detail.value?.promise_signed_at))
/** 各项得分草稿 key → 字符串 */
const scoreDraft = ref<Record<string, string>>({})
/** 项目总分草稿:始终用字符串,避免 number 输入把值变成 number 后 .trim 崩溃导致弹窗只剩遮罩 */
const totalDraft = ref('')
const scoreSubmitting = ref(false)
/** 失焦过的得分项,用于空值提示 */
const scoreBlurred = ref<Record<string, boolean>>({})
/** 点击提交后展示全部未填提示 */
const totalBlurred = ref(false)
const scoreShowErrors = ref(false)
function extractScoresFromPayload(payload: Record<string, unknown> | undefined): Record<string, unknown> {
if (!payload) return {}
const nested = payload.scores
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
return nested as Record<string, unknown>
}
return payload
const fullScore = computed(() => Number(detail.value?.scoring_sheet?.full_score ?? 100) || 100)
function totalDraftText(): string {
return String(totalDraft.value ?? '').trim()
}
/** 文本输入绑定:过滤非法字符,始终写回 string */
const totalDraftModel = computed({
get: () => totalDraft.value,
set: (v: string | number | null | undefined) => {
const raw = v == null ? '' : String(v)
// 允许空、整数/小数、一位前导负号(校验时再拦负数)
const cleaned = raw.replace(/[^\d.-]/g, '').replace(/(?!^)-/g, '').replace(/(\..*)\./g, '$1')
totalDraft.value = cleaned
},
})
function syncScoreDraft(): void {
const d = detail.value
const items = d?.scoring_sheet?.items
if (!items?.length) {
scoreDraft.value = {}
scoreBlurred.value = {}
if (!d?.scoring_sheet?.items?.length) {
totalDraft.value = ''
totalBlurred.value = false
scoreShowErrors.value = false
return
}
const src = extractScoresFromPayload(d?.my_review_score?.payload_json)
const next: Record<string, string> = {}
for (const item of items) {
const v = src[item.key]
if (typeof v === 'number' && Number.isFinite(v)) {
next[item.key] = String(v)
} else if (typeof v === 'string' && v.trim() !== '') {
next[item.key] = v.trim()
} else {
next[item.key] = ''
}
const line = d.my_review_score?.line_total
const fromPayload = d.my_review_score?.payload_json?.line_total
const raw =
line != null && String(line).trim() !== ''
? String(line)
: fromPayload != null && String(fromPayload).trim() !== ''
? String(fromPayload)
: ''
if (raw === '') {
totalDraft.value = ''
} else {
const n = Number(raw)
// Number('') === 0,空分必须保持空串,不能写成默认 0
totalDraft.value = Number.isFinite(n) ? rtrimZeros(n.toFixed(4)) : ''
}
scoreDraft.value = next
scoreBlurred.value = {}
totalBlurred.value = false
scoreShowErrors.value = false
}
@ -201,73 +208,37 @@ const scoringRows = computed(() => {
return rows
})
function getScoreError(item: ScoringSheetItem): string | null {
const raw = String(scoreDraft.value[item.key] ?? '').trim()
function getTotalError(): string | null {
const raw = totalDraftText()
if (raw === '') {
if (scoreShowErrors.value || scoreBlurred.value[item.key]) {
return '请填写得分'
}
if (scoreShowErrors.value || totalBlurred.value) return '请填写项目总分'
return null
}
const n = Number(raw)
if (!Number.isFinite(n)) return '须为数字'
if (n < 0) return '不能小于 0'
if (n > item.max_score) return `不得超过 ${formatMax(item.max_score)} 分`
if (n > fullScore.value) return `不得超过 ${formatMax(fullScore.value)} 分`
return null
}
function onScoreBlur(key: string): void {
scoreBlurred.value = { ...scoreBlurred.value, [key]: true }
function onTotalBlur(): void {
totalBlurred.value = true
}
const draftLineTotal = computed(() => {
let sum = 0
let allValid = scoringItems.value.length > 0
for (const item of scoringItems.value) {
const raw = String(scoreDraft.value[item.key] ?? '').trim()
if (raw === '') {
allValid = false
continue
}
const n = Number(raw)
if (!Number.isFinite(n) || n < 0 || n > item.max_score) {
allValid = false
continue
}
sum += n
}
return { sum, allValid }
})
const canSubmitScore = computed(() => {
if (!detail.value?.scoring_allowed || !scoringItems.value.length) return false
for (const item of scoringItems.value) {
const raw = String(scoreDraft.value[item.key] ?? '').trim()
if (raw === '') return false
const n = Number(raw)
if (!Number.isFinite(n) || n < 0 || n > item.max_score) return false
}
return true
const raw = totalDraftText()
if (raw === '') return false
const n = Number(raw)
return Number.isFinite(n) && n >= 0 && n <= fullScore.value
})
const scoreFormHint = computed(() => {
if (!scoringItems.value.length) return ''
let empty = 0
let over = 0
for (const item of scoringItems.value) {
const raw = String(scoreDraft.value[item.key] ?? '').trim()
if (raw === '') {
empty += 1
continue
}
const n = Number(raw)
if (!Number.isFinite(n) || n < 0 || n > item.max_score) over += 1
}
const parts: string[] = []
// 未填写仅在点击提交后汇总提示;超分/无效随时提示
if (scoreShowErrors.value && empty > 0) parts.push(`${empty} 项未填写`)
if (over > 0) parts.push(`${over} 项超出分值或无效`)
return parts.join(',')
const err = getTotalError()
return err && (scoreShowErrors.value || totalBlurred.value || totalDraftText() !== '')
? err
: ''
})
const statusBadgeText = computed(() => {
@ -392,19 +363,44 @@ async function downloadAttachment(f: FileItem) {
}
function buildSubmitPayload(): Record<string, unknown> {
const scores: Record<string, number> = {}
for (const item of scoringItems.value) {
const n = Number(String(scoreDraft.value[item.key] ?? '').trim())
scores[item.key] = Number.isFinite(n) ? n : 0
}
return { scores }
const n = Number(totalDraftText())
return { line_total: Number.isFinite(n) ? n : 0 }
}
function hideScoreModal(): void {
function cleanupModalArtifacts(): void {
if (typeof document === 'undefined') return
const el = document.getElementById('reviewerScoreModal')
if (!el) return
Modal.getOrCreateInstance(el).hide()
document.querySelectorAll('.modal-backdrop').forEach((n) => n.remove())
document.body.classList.remove('modal-open')
document.body.style.removeProperty('overflow')
document.body.style.removeProperty('padding-right')
}
function hideScoreModal(): Promise<void> {
return new Promise((resolve) => {
if (typeof document === 'undefined') {
resolve()
return
}
const el = document.getElementById('reviewerScoreModal')
if (!el) {
cleanupModalArtifacts()
resolve()
return
}
let settled = false
const finish = () => {
if (settled) return
settled = true
el.removeEventListener('hidden.bs.modal', onHidden)
cleanupModalArtifacts()
resolve()
}
const onHidden = () => finish()
el.addEventListener('hidden.bs.modal', onHidden)
const instance = Modal.getInstance(el) ?? Modal.getOrCreateInstance(el)
instance.hide()
window.setTimeout(finish, 450)
})
}
function openScoreModal(): void {
@ -413,6 +409,8 @@ function openScoreModal(): void {
return
}
scoreShowErrors.value = false
totalBlurred.value = false
cleanupModalArtifacts()
if (typeof document === 'undefined') return
const el = document.getElementById('reviewerScoreModal')
if (!el) return
@ -433,7 +431,7 @@ async function submitReviewScore() {
}
if (!canSubmitScore.value) {
scoreShowErrors.value = true
ElMessage.warning(scoreFormHint.value || '请为每一项填写得分(可为 0,且不得超过该项分值)')
ElMessage.warning(getTotalError() || '请填写 0~100 之间的项目总分')
return
}
@ -463,6 +461,9 @@ async function submitReviewScore() {
ElMessage.error(msg)
return
}
// 先关弹窗并清理遮罩,再更新详情,避免 Vue 重绘与 Bootstrap 关闭动画冲突
await hideScoreModal()
const data = (body as { data?: Partial<DetailPayload> }).data
if (detail.value && data) {
if (data.my_review_score !== undefined) {
@ -481,7 +482,6 @@ async function submitReviewScore() {
? String((body as { message: string }).message)
: '提交成功',
)
hideScoreModal()
} catch {
ElMessage.error('网络错误,提交失败')
} finally {
@ -513,7 +513,7 @@ async function submitReviewScore() {
<div v-else-if="detail" class="card mb-4 border-0 shadow-sm">
<div class="card-body pb-4">
<ApplicationSignupDetailFields :detail="detail" />
<ApplicationSignupDetailFields :detail="detail" :show-recommend="false" />
<div class="row g-3 mt-1">
<div class="col-12">
@ -639,10 +639,10 @@ async function submitReviewScore() {
<button type="button" class="btn-close scoring-modal-close" data-bs-dismiss="modal" aria-label="关闭" />
</div>
<div class="modal-body">
<p class="small text-secondary mb-2 text-center">
请为每一项填写得分(可为 0);单项不得超过「分值」上限;提交后自动汇总为项目得分。
<p class="small text-secondary mb-2 text-center scoring-modal-hint">
下表为评审参考;请在底部填写项目总分(0~{{ formatMax(fullScore) }},可为 0)。
</p>
<p v-if="scoreFormHint" class="small text-danger mb-3 text-center" role="alert">{{ scoreFormHint }}</p>
<p v-if="scoreFormHint" class="small text-danger mb-2 text-center" role="alert">{{ scoreFormHint }}</p>
<div class="table-responsive scoring-sheet-wrap">
<table class="table table-bordered align-middle mb-0 scoring-sheet-table">
<colgroup>
@ -651,7 +651,6 @@ async function submitReviewScore() {
<col class="col-indicator" />
<col class="col-criteria" />
<col class="col-max" />
<col class="col-score" />
</colgroup>
<thead>
<tr>
@ -660,7 +659,6 @@ async function submitReviewScore() {
<th class="text-nowrap text-center">评审指标</th>
<th class="text-center">核心评审要素(评委打分依据)</th>
<th class="text-nowrap text-center">分值</th>
<th class="text-nowrap text-center">得分</th>
</tr>
</thead>
<tbody>
@ -676,39 +674,30 @@ async function submitReviewScore() {
<td class="text-center fw-semibold scoring-indicator">{{ row.item.title }}</td>
<td class="small text-secondary scoring-criteria">{{ row.item.criteria || '—' }}</td>
<td class="text-center">{{ formatMax(row.item.max_score) }}</td>
<td class="scoring-score-cell text-center">
<input
v-model="scoreDraft[row.item.key]"
class="form-control form-control-sm text-center scoring-score-input"
:class="{ 'is-invalid': !!getScoreError(row.item) }"
type="number"
min="0"
:max="row.item.max_score"
step="any"
required
autocomplete="off"
:aria-label="`${row.item.title}得分`"
@blur="onScoreBlur(row.item.key)"
/>
<div v-if="getScoreError(row.item)" class="invalid-feedback d-block text-center">
{{ getScoreError(row.item) }}
</div>
</td>
</tr>
<tr class="scoring-total-row">
<td colspan="4" class="text-end fw-semibold">项目得分</td>
<td class="text-center text-secondary">{{ detail?.scoring_sheet?.full_score ?? 100 }}</td>
<td class="text-center fw-semibold">
{{
draftLineTotal.allValid
? rtrimZeros(draftLineTotal.sum.toFixed(4))
: '—'
}}
</td>
</tr>
</tbody>
</table>
</div>
<div class="scoring-total-block">
<label class="form-label mb-1 fw-semibold" for="reviewer-total-score-input">项目总分</label>
<div class="scoring-total-row-inner">
<input
id="reviewer-total-score-input"
v-model="totalDraftModel"
class="form-control scoring-total-input text-center"
:class="{ 'is-invalid': !!getTotalError() }"
type="text"
inputmode="decimal"
autocomplete="off"
placeholder="请输入总分"
aria-label="项目总分"
@blur="onTotalBlur"
@keydown.enter.prevent="submitReviewScore"
/>
<span class="scoring-total-full small text-secondary">满分 {{ formatMax(fullScore) }}</span>
</div>
<div v-if="getTotalError()" class="invalid-feedback d-block">{{ getTotalError() }}</div>
</div>
</div>
<div class="modal-footer flex-wrap gap-2">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
@ -843,11 +832,39 @@ async function submitReviewScore() {
}
.scoring-sheet-table .col-max {
width: 4.5rem;
width: 5rem;
}
.scoring-modal-hint {
flex: 0 0 auto;
}
.scoring-total-block {
flex: 0 0 auto;
margin-top: 0.85rem;
padding: 0.85rem 1rem;
border: 1px solid #dce3ec;
border-radius: 0.5rem;
background: #f3f6fa;
}
.scoring-total-row-inner {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.scoring-total-input {
width: min(16rem, 100%);
max-width: 100%;
font-size: 1.125rem;
font-weight: 600;
padding: 0.55rem 0.75rem;
}
.scoring-sheet-table .col-score {
width: 10rem;
.scoring-total-full {
white-space: nowrap;
}
.scoring-sheet-table th {
@ -875,24 +892,6 @@ async function submitReviewScore() {
overflow-wrap: break-word;
white-space: normal;
}
.scoring-score-cell {
vertical-align: middle;
}
.scoring-score-input {
width: 100%;
min-width: 4.5rem;
}
.scoring-score-cell .invalid-feedback {
font-size: 0.75rem;
margin-top: 0.2rem;
}
.scoring-total-row td {
background: #f3f6fa;
}
</style>
<style>
@ -946,6 +945,11 @@ async function submitReviewScore() {
flex-direction: column;
}
#reviewerScoreModal .scoring-sheet-wrap {
flex: 1 1 auto;
min-height: 0;
}
#reviewerPledgeModal .promise-doc-scroll {
max-height: min(38vh, calc(100dvh - 15rem));
overflow-y: auto;

Loading…
Cancel
Save