评审打分

master
lion 3 months ago
parent f2451486b4
commit cf7abe4599

@ -86,6 +86,22 @@ export interface FormSchemaPayload {
is_published?: boolean
}
export interface TrackScoringSheetItem {
key: string
sort: number
category: string
title: string
criteria: string
max_score: number
}
export interface TrackScoringSheet {
version: number
title: string
full_score: number
items: TrackScoringSheetItem[]
}
export interface CompetitionTrackRow {
id: number
competition_id: number
@ -94,6 +110,8 @@ export interface CompetitionTrackRow {
description: string | null
sort: number
is_enabled: boolean
scoring_sheet_json?: TrackScoringSheet | null
scoring_sheet_configured?: boolean
created_at?: string
updated_at?: string
}
@ -104,6 +122,7 @@ export interface CompetitionTrackPayload {
description: string | null
sort: number
is_enabled: boolean
scoring_sheet_json?: TrackScoringSheet | null
}
export interface AdminApplicationListParams {

@ -42,6 +42,7 @@ import type {
PublicSourceChannelRow,
SignupChannelPayload,
SignupChannelRow,
TrackScoringSheetItem,
} from '../../../api/admin/types'
import {
brandingFormFromApi,
@ -133,8 +134,25 @@ const trackForm = ref({
description: '',
sort: 0,
is_enabled: true,
scoring_sheet_title: '',
scoring_items: [] as TrackScoringSheetItem[],
})
function emptyScoringItem(sort = 1): TrackScoringSheetItem {
return {
key: '',
sort,
category: '通用指标',
title: '',
criteria: '',
max_score: 0,
}
}
const trackSheetMaxSum = computed(() =>
trackForm.value.scoring_items.reduce((s, r) => s + (Number(r.max_score) || 0), 0),
)
const channelDialogVisible = ref(false)
const channelEditingId = ref<number | null>(null)
const channelSaving = ref(false)
@ -576,6 +594,18 @@ function openTrackModal(row?: CompetitionTrackRow) {
return
}
trackEditingId.value = row?.id ?? null
const sheet = row?.scoring_sheet_json
const items =
sheet && Array.isArray(sheet.items) && sheet.items.length
? sheet.items.map((it, idx) => ({
key: String(it.key ?? ''),
sort: Number(it.sort) || idx + 1,
category: String(it.category ?? ''),
title: String(it.title ?? ''),
criteria: String(it.criteria ?? ''),
max_score: Number(it.max_score) || 0,
}))
: [emptyScoringItem(1)]
trackForm.value = row
? {
track_code: row.track_code,
@ -583,6 +613,8 @@ function openTrackModal(row?: CompetitionTrackRow) {
description: row.description ?? '',
sort: row.sort,
is_enabled: row.is_enabled,
scoring_sheet_title: String(sheet?.title ?? ''),
scoring_items: items,
}
: {
track_code: '',
@ -590,10 +622,30 @@ function openTrackModal(row?: CompetitionTrackRow) {
description: '',
sort: (tracks.value[tracks.value.length - 1]?.sort ?? 0) + 10,
is_enabled: true,
scoring_sheet_title: '',
scoring_items: [emptyScoringItem(1)],
}
trackDialogVisible.value = true
}
function addTrackScoringItem() {
const nextSort = (trackForm.value.scoring_items[trackForm.value.scoring_items.length - 1]?.sort ?? 0) + 1
trackForm.value.scoring_items = [...trackForm.value.scoring_items, emptyScoringItem(nextSort)]
}
function removeTrackScoringItem(i: number) {
if (trackForm.value.scoring_items.length <= 1) {
trackForm.value.scoring_items = [emptyScoringItem(1)]
return
}
trackForm.value.scoring_items = trackForm.value.scoring_items.filter((_, idx) => idx !== i)
}
function clearTrackScoringSheet() {
trackForm.value.scoring_sheet_title = ''
trackForm.value.scoring_items = []
}
async function refreshTracks() {
const cid = competitionId.value
if (!cid) {
@ -622,6 +674,36 @@ async function saveTrack() {
ElMessage.warning('请填写赛道编码与标题')
return
}
const items = trackForm.value.scoring_items
.map((r, idx) => ({
key: r.key.trim(),
sort: Number(r.sort) || idx + 1,
category: r.category.trim(),
title: r.title.trim(),
criteria: r.criteria.trim(),
max_score: Number(r.max_score) || 0,
}))
.filter((r) => r.key || r.title || r.category)
if (items.length === 0) {
payload.scoring_sheet_json = null
} else {
const sum = items.reduce((s, r) => s + r.max_score, 0)
if (Math.abs(sum - 100) > 0.0001) {
ElMessage.warning(`打分表各项分值之和须为 100,当前为 ${sum}`)
return
}
payload.scoring_sheet_json = {
version: 1,
title:
trackForm.value.scoring_sheet_title.trim() ||
`评分表(${payload.title})`,
full_score: 100,
items,
}
}
try {
if (trackEditingId.value) {
await updateTrack(cid, trackEditingId.value, payload)
@ -692,6 +774,31 @@ 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`
@ -1360,7 +1467,9 @@ onMounted(() => {
<el-tab-pane v-if="!isCreate()" label="赛道管理" name="tracks" lazy>
<div v-loading="tracksLoading" class="tracks-pane">
<div class="pane-toolbar pane-toolbar--split">
<p class="form-hint pane-toolbar-tip">维护本场主题赛道。新建赛事请先保存基础信息。</p>
<p class="form-hint pane-toolbar-tip">
维护本场主题赛道与打分表(满分 100)。未配置打分表的赛道禁止评审。新建赛事请先保存基础信息。
</p>
<el-button type="primary" @click="openTrackModal()">新增赛道</el-button>
</div>
<el-table :data="tracks" stripe border empty-text="暂无赛道" class="workspace-table">
@ -1371,6 +1480,13 @@ onMounted(() => {
<span class="cell-muted">{{ row.description || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="打分表" width="100">
<template #default="{ row }">
<el-tag :type="row.scoring_sheet_configured ? 'success' : 'danger'" effect="plain" size="small">
{{ row.scoring_sheet_configured ? '已配置' : '未配置' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" />
<el-table-column label="启用" width="80">
<template #default="{ row }">{{ row.is_enabled ? '是' : '否' }}</template>
@ -1920,12 +2036,17 @@ onMounted(() => {
<span>均分 {{ formatScore(applicationDetail.team_avg) }}</span>
</div>
<el-table :data="applicationDetail.review_scores" border empty-text="暂无评分" class="workspace-table">
<el-table-column prop="reviewer_name" label="评审员" min-width="140">
<el-table-column prop="reviewer_name" label="评审员" min-width="120">
<template #default="{ row }">{{ row.reviewer_name || `评审员 #${row.reviewer_id}` }}</template>
</el-table-column>
<el-table-column label="分数" width="100">
<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>
@ -2101,9 +2222,10 @@ onMounted(() => {
<el-dialog
v-model="trackDialogVisible"
:title="trackEditingId ? '编辑赛道' : '新增赛道'"
width="520px"
width="920px"
destroy-on-close
align-center
class="track-scoring-dialog"
>
<el-form label-position="top">
<el-form-item label="赛道编码">
@ -2127,6 +2249,61 @@ onMounted(() => {
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">打分表(满分 100,未配置则禁止评审)</el-divider>
<el-form-item label="评分表标题">
<el-input v-model="trackForm.scoring_sheet_title" placeholder="如:…初复赛评分表(某某赛道)" autocomplete="off" />
</el-form-item>
<div class="pane-toolbar pane-toolbar--split mb-2">
<p class="form-hint pane-toolbar-tip">
当前分值合计:
<strong :class="Math.abs(trackSheetMaxSum - 100) < 0.0001 ? 'text-ok' : 'text-bad'">
{{ trackSheetMaxSum }}
</strong>
/ 100
</p>
<div>
<el-button size="small" @click="clearTrackScoringSheet">清空打分表</el-button>
<el-button size="small" type="primary" @click="addTrackScoringItem">新增指标</el-button>
</div>
</div>
<el-table :data="trackForm.scoring_items" border size="small" class="workspace-table">
<el-table-column label="序号" width="72">
<template #default="{ row }">
<el-input-number v-model="row.sort" :min="1" :step="1" controls-position="right" size="small" />
</template>
</el-table-column>
<el-table-column label="指标分类" min-width="110">
<template #default="{ row }">
<el-input v-model="row.category" size="small" placeholder="通用/特色" />
</template>
</el-table-column>
<el-table-column label="评审指标" min-width="120">
<template #default="{ row }">
<el-input v-model="row.title" size="small" />
</template>
</el-table-column>
<el-table-column label="key" min-width="120">
<template #default="{ row }">
<el-input v-model="row.key" size="small" placeholder="英文字段名" />
</template>
</el-table-column>
<el-table-column label="核心评审要素" min-width="180">
<template #default="{ row }">
<el-input v-model="row.criteria" type="textarea" :rows="2" size="small" />
</template>
</el-table-column>
<el-table-column label="分值" width="100">
<template #default="{ row }">
<el-input-number v-model="row.max_score" :min="0" :max="100" :step="1" controls-position="right" size="small" />
</template>
</el-table-column>
<el-table-column label="" width="64" align="center">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="removeTrackScoringItem($index)">删</el-button>
</template>
</el-table-column>
</el-table>
</el-form>
<template #footer>
<el-button @click="trackDialogVisible = false">取消</el-button>
@ -2270,6 +2447,18 @@ onMounted(() => {
min-height: 120px;
}
.text-ok {
color: var(--el-color-success);
}
.text-bad {
color: var(--el-color-danger);
}
.mb-2 {
margin-bottom: 8px;
}
.channels-pane {
min-height: 120px;
}

@ -55,6 +55,36 @@ 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
@ -172,12 +202,12 @@ 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><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">{{ s.comment || '—' }}</td>
<td class="cell-wrap small text-secondary">{{ scoreItemLines(s.payload_json) || s.comment || '—' }}</td>
<td>{{ s.updated_at || '—' }}</td>
</tr>
</tbody>

@ -19,11 +19,20 @@ interface FileItem {
url: string
}
interface ReviewSchemaField {
interface ScoringSheetItem {
key: string
type: 'number' | 'text' | 'textarea'
label: string
required?: boolean
sort: number
category: string
title: string
criteria: string
max_score: number
}
interface ScoringSheet {
version: number
title: string
full_score: number
items: ScoringSheetItem[]
}
interface MyReviewScorePayload {
@ -54,11 +63,9 @@ interface DetailPayload {
submitted_at: string
score_display: string
score_is_pending: boolean
review_schema: {
id: number | null
schema_json: ReviewSchemaField[]
is_default: boolean
}
scoring_allowed: boolean
scoring_blocked_reason: string | null
scoring_sheet: ScoringSheet | null
my_review_score: MyReviewScorePayload | null
files: FileItem[]
promise_signed: boolean
@ -83,7 +90,6 @@ const pledgeModalHeading = computed(() => {
return n ? `${n} 赛事承诺书` : '赛事承诺书'
})
/** 与选手端承诺书弹窗「日期」行一致:`2026年5月11日`(仅日期,取自接口签署时间字符串) */
function formatPledgeCnDate(at: string | undefined): string {
const t = (at ?? '').trim()
if (!t) return '—'
@ -107,33 +113,47 @@ const loadError = ref('')
const pledgeSignedDateCn = computed(() => formatPledgeCnDate(detail.value?.promise_signed_at))
/** 项目评分弹窗内编辑草稿(key → 字符串,便于绑定 input) */
/** 各项得分草稿 key → 字符串 */
const scoreDraft = ref<Record<string, string>>({})
const scoreSubmitting = ref(false)
/** 失焦过的得分项,用于空值提示 */
const scoreBlurred = ref<Record<string, boolean>>({})
/** 点击提交后展示全部未填提示 */
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
}
function syncScoreDraft(): void {
const d = detail.value
if (!d?.review_schema?.schema_json?.length) {
const items = d?.scoring_sheet?.items
if (!items?.length) {
scoreDraft.value = {}
scoreBlurred.value = {}
scoreShowErrors.value = false
return
}
const src = d.my_review_score?.payload_json ?? {}
const src = extractScoresFromPayload(d?.my_review_score?.payload_json)
const next: Record<string, string> = {}
for (const f of d.review_schema.schema_json) {
const v = src[f.key]
if (f.type === 'number') {
if (typeof v === 'number' && Number.isFinite(v)) {
next[f.key] = String(v)
} else if (typeof v === 'string' && v.trim() !== '') {
next[f.key] = v.trim()
} else {
next[f.key] = ''
}
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[f.key] = typeof v === 'string' ? v : v != null ? String(v) : ''
next[item.key] = ''
}
}
scoreDraft.value = next
scoreBlurred.value = {}
scoreShowErrors.value = false
}
watch(detail, () => syncScoreDraft(), { deep: true })
@ -142,6 +162,108 @@ function rtrimZeros(s: string): string {
return s.replace(/\.?0+$/, '')
}
function formatMax(n: number): string {
return rtrimZeros(Number(n).toFixed(4))
}
const scoringItems = computed(() => detail.value?.scoring_sheet?.items ?? [])
/** 合并相同「指标分类」单元格 */
const scoringRows = computed(() => {
const items = scoringItems.value
const rows: Array<{
item: ScoringSheetItem
idx: number
categoryRowspan: number
}> = []
let i = 0
while (i < items.length) {
const cat = items[i].category
let span = 1
while (i + span < items.length && items[i + span].category === cat) {
span += 1
}
for (let j = 0; j < span; j++) {
rows.push({
item: items[i + j],
idx: i + j,
categoryRowspan: j === 0 ? span : 0,
})
}
i += span
}
return rows
})
function getScoreError(item: ScoringSheetItem): string | null {
const raw = String(scoreDraft.value[item.key] ?? '').trim()
if (raw === '') {
if (scoreShowErrors.value || scoreBlurred.value[item.key]) {
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)} 分`
return null
}
function onScoreBlur(key: string): void {
scoreBlurred.value = { ...scoreBlurred.value, [key]: 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 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 statusBadgeText = computed(() => {
const d = detail.value
if (!d) return ''
@ -152,7 +274,6 @@ const planFile = computed(() => detail.value?.files.find((f) => f.kind === 'plan
const supportingFiles = computed(() => detail.value?.files.filter((f) => f.kind === 'supporting') ?? [])
/** 手写签名为选手端 canvas 导出的 data URL,或未带前缀的 base64 PNG */
const signatureDataUrl = computed((): string => {
const raw = detail.value?.promise_signature
if (raw == null || typeof raw !== 'string') return ''
@ -241,28 +362,12 @@ async function downloadAttachment(f: FileItem) {
}
function buildSubmitPayload(): Record<string, unknown> {
const d = detail.value
const schema = d?.review_schema?.schema_json
if (!schema?.length) return {}
const out: Record<string, unknown> = {}
for (const f of schema) {
const raw = scoreDraft.value[f.key] ?? ''
if (f.type === 'number') {
const t = String(raw).trim()
if (t === '') {
if (f.required) {
out[f.key] = ''
}
} else {
const n = Number(t)
out[f.key] = Number.isFinite(n) ? n : 0
}
} else {
out[f.key] = String(raw)
}
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 out
return { scores }
}
function hideScoreModal(): void {
@ -272,6 +377,18 @@ function hideScoreModal(): void {
Modal.getOrCreateInstance(el).hide()
}
function openScoreModal(): void {
if (!detail.value?.scoring_allowed) {
ElMessage.warning(detail.value?.scoring_blocked_reason || '该赛道未配置打分表,暂不可评审')
return
}
scoreShowErrors.value = false
if (typeof document === 'undefined') return
const el = document.getElementById('reviewerScoreModal')
if (!el) return
Modal.getOrCreateInstance(el).show()
}
async function submitReviewScore() {
const id = applicationId.value
const s = slug.value
@ -280,9 +397,13 @@ async function submitReviewScore() {
ElMessage.warning('未登录或参数无效')
return
}
const d = detail.value
if (!d?.review_schema?.schema_json?.length) {
ElMessage.warning('未加载评审表单')
if (!detail.value?.scoring_allowed || !scoringItems.value.length) {
ElMessage.warning(detail.value?.scoring_blocked_reason || '该赛道未配置打分表,暂不可评审')
return
}
if (!canSubmitScore.value) {
scoreShowErrors.value = true
ElMessage.warning(scoreFormHint.value || '请为每一项填写得分(可为 0,且不得超过该项分值)')
return
}
@ -325,7 +446,11 @@ async function submitReviewScore() {
}
}
syncScoreDraft()
ElMessage.success(typeof (body as { message?: unknown }).message === 'string' ? String((body as { message: string }).message) : '提交成功')
ElMessage.success(
typeof (body as { message?: unknown }).message === 'string'
? String((body as { message: string }).message)
: '提交成功',
)
hideScoreModal()
} catch {
ElMessage.error('网络错误,提交失败')
@ -333,6 +458,7 @@ async function submitReviewScore() {
scoreSubmitting.value = false
}
}
</script>
<template>
@ -405,13 +531,16 @@ async function submitReviewScore() {
type="button"
class="btn btn-primary px-4"
style="background-color: var(--primary, #052d62); border-color: var(--primary, #052d62)"
data-bs-toggle="modal"
data-bs-target="#reviewerScoreModal"
:disabled="!detail.scoring_allowed"
@click="openScoreModal"
>
{{ detail.score_is_pending ? '项目评分' : '修改项目评分' }}
</button>
<span v-if="detail.my_review_score" class="small text-secondary"
>最近保存:{{ detail.my_review_score.updated_at }} · 合计
<span v-if="!detail.scoring_allowed" class="small text-danger">{{
detail.scoring_blocked_reason || '该赛道未配置打分表,暂不可评审'
}}</span>
<span v-else-if="detail.my_review_score" class="small text-secondary"
>最近保存:{{ detail.my_review_score.updated_at }} · 项目得分
{{ rtrimZeros(Number(detail.my_review_score.line_total).toFixed(4)) }} 分</span
>
</div>
@ -419,7 +548,6 @@ async function submitReviewScore() {
</div>
</div>
<!-- 参赛承诺书弹窗(与选手端结构与样式一致) -->
<div
id="reviewerPledgeModal"
class="modal fade"
@ -470,39 +598,86 @@ async function submitReviewScore() {
</div>
</div>
<!-- 项目评分(Schema 驱动) -->
<!-- 赛道打分表 -->
<div id="reviewerScoreModal" class="modal fade" tabindex="-1" aria-labelledby="reviewerScoreModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable reviewer-detail-modal reviewer-detail-modal--score">
<div class="modal-content">
<div class="modal-header">
<h5 id="reviewerScoreModalLabel" class="modal-title">项目评分</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="关闭" />
<div class="modal-header scoring-modal-header">
<h5 id="reviewerScoreModalLabel" class="modal-title scoring-modal-title w-100 text-center">
{{ detail?.scoring_sheet?.title || '项目评分' }}
</h5>
<button type="button" class="btn-close scoring-modal-close" data-bs-dismiss="modal" aria-label="关闭" />
</div>
<div class="modal-body">
<p v-if="detail?.review_schema?.is_default" class="small text-secondary mb-3">
本场赛事未绑定自定义评审表,已使用系统默认维度。
<p class="small text-secondary mb-2 text-center">
请为每一项填写得分(可为 0);单项不得超过「分值」上限;提交后自动汇总为项目得分。
</p>
<div v-for="field in detail?.review_schema?.schema_json ?? []" :key="field.key" class="mb-3">
<label class="form-label">
{{ field.label }}
<span v-if="field.required" class="text-danger">*</span>
</label>
<input
v-if="field.type === 'number'"
v-model="scoreDraft[field.key]"
class="form-control"
type="number"
step="any"
autocomplete="off"
/>
<input
v-else-if="field.type === 'text'"
v-model="scoreDraft[field.key]"
class="form-control"
type="text"
autocomplete="off"
/>
<textarea v-else v-model="scoreDraft[field.key]" class="form-control" rows="4" />
<p v-if="scoreFormHint" class="small text-danger mb-3 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>
<col class="col-seq" />
<col class="col-category" />
<col class="col-indicator" />
<col class="col-criteria" />
<col class="col-max" />
<col class="col-score" />
</colgroup>
<thead>
<tr>
<th class="text-nowrap text-center">序号</th>
<th class="text-nowrap text-center">指标分类</th>
<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>
<tr v-for="row in scoringRows" :key="row.item.key">
<td class="text-center text-secondary">{{ row.item.sort || row.idx + 1 }}</td>
<td
v-if="row.categoryRowspan > 0"
:rowspan="row.categoryRowspan"
class="text-center scoring-category"
>
{{ row.item.category }}
</td>
<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>
<div class="modal-footer flex-wrap gap-2">
@ -580,10 +755,117 @@ async function submitReviewScore() {
font-weight: 600;
margin: 0.75rem 0 0.5rem;
}
.scoring-modal-header {
position: relative;
}
.scoring-modal-title {
margin: 0;
padding-inline: 2rem;
font-size: 1.05rem;
line-height: 1.4;
}
.scoring-modal-close {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
margin: 0;
}
.scoring-sheet-wrap {
flex: 1 1 auto;
min-height: 0;
max-height: min(58vh, calc(100dvh - 14rem));
overflow: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 0.375rem;
width: 100%;
}
.scoring-sheet-table {
table-layout: fixed;
width: 100%;
/* 窄屏保底宽度,横向滚动;宽屏由 100% 撑满弹窗 */
min-width: 52rem;
margin-bottom: 0;
font-size: 0.875rem;
}
.scoring-sheet-table .col-seq {
width: 3.5rem;
}
.scoring-sheet-table .col-category {
width: 6rem;
}
.scoring-sheet-table .col-indicator {
width: 8rem;
}
.scoring-sheet-table .col-criteria {
width: auto;
}
.scoring-sheet-table .col-max {
width: 4.5rem;
}
.scoring-sheet-table .col-score {
width: 10rem;
}
.scoring-sheet-table th {
background: #f8f9fa;
font-weight: 600;
text-align: center;
vertical-align: middle;
white-space: normal;
}
.scoring-category {
vertical-align: middle;
font-weight: 600;
writing-mode: horizontal-tb;
background: #fafbfc;
}
.scoring-indicator {
vertical-align: middle;
}
.scoring-criteria {
line-height: 1.45;
word-break: normal;
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>
/* 与选手端 #promiseSignModal 对齐,便于评审承诺书过长时正文区滚动 */
#reviewerPledgeModal .modal-body {
max-height: none;
overflow: visible;
@ -608,10 +890,30 @@ async function submitReviewScore() {
}
#reviewerScoreModal .reviewer-detail-modal--score {
--bs-modal-width: min(24rem, calc(100vw - 1.25rem));
max-width: min(24rem, calc(100vw - 1.25rem));
margin-top: 0.65rem;
margin-bottom: 0.65rem;
--bs-modal-width: min(72rem, calc(100vw - 1.25rem));
max-width: min(72rem, calc(100vw - 1.25rem));
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
#reviewerScoreModal .modal-content {
max-height: calc(100dvh - 1rem);
display: flex;
flex-direction: column;
overflow: hidden;
}
#reviewerScoreModal .modal-header,
#reviewerScoreModal .modal-footer {
flex: 0 0 auto;
}
#reviewerScoreModal .modal-body {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
#reviewerPledgeModal .promise-doc-scroll {
@ -620,4 +922,20 @@ async function submitReviewScore() {
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
@media (max-width: 767.98px) {
#reviewerScoreModal .reviewer-detail-modal--score {
--bs-modal-width: calc(100vw - 0.75rem);
max-width: calc(100vw - 0.75rem);
margin: 0.35rem auto;
}
#reviewerScoreModal .modal-content {
max-height: calc(100dvh - 0.7rem);
}
#reviewerScoreModal .modal-body {
padding: 0.75rem;
}
}
</style>

Loading…
Cancel
Save