master
lion 1 month ago
parent a7f8878ace
commit 412f3799fb

@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>沪苏协同新兴产业科创大赛</title>
<link rel="icon" type="image/png" href="/favicon.png?v=20260829c">
<style>
:root {
--blue-950: #061a42;

@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>沪苏协同新兴产业科创大赛报名系统</title>
<link rel="icon" type="image/png" href="/favicon.png?v=20260829c">
<link rel="stylesheet" href="./assets/bootstrap.min.css" />
<link rel="stylesheet" href="./assets/prototype.css" />
</head>

@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shanghai–Suzhou Collaborative Emerging Industries Sci-Tech Innovation Competition Registration System</title>
<link rel="icon" type="image/png" href="/favicon.png?v=20260829c">
<link rel="stylesheet" href="/frontend/assets/prototype.css" />
<script src="/frontend/assets/prototype-scale.js"></script>
</head>

@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>沪苏协同新兴产业科创大赛报名系统</title>
<link rel="icon" type="image/png" href="/favicon.png?v=20260829c">
<link rel="stylesheet" href="./assets/prototype.css" />
</head>
<body class="login-page-wls">

@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>沪苏协同新兴产业科创大赛报名系统</title>
<link rel="icon" type="image/png" href="/favicon.png?v=20260829c">
<link rel="stylesheet" href="../assets/bootstrap.min.css" />
<link rel="stylesheet" href="../assets/prototype.css" />
</head>

@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>沪苏协同新兴产业科创大赛报名系统</title>
<link rel="icon" type="image/png" href="/favicon.png?v=20260829c">
<link rel="stylesheet" href="../assets/bootstrap.min.css" />
<link rel="stylesheet" href="../assets/prototype.css" />
</head>

@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>沪苏协同新兴产业科创大赛报名系统</title>
<link rel="icon" type="image/png" href="/favicon.png?v=20260829c">
<link rel="stylesheet" href="../assets/bootstrap.min.css" />
<link rel="stylesheet" href="../assets/prototype.css" />
</head>

@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>沪苏协同新兴产业科创大赛报名系统</title>
<link rel="icon" type="image/png" href="/favicon.png?v=20260829c">
<link rel="stylesheet" href="../assets/bootstrap.min.css" />
<link rel="stylesheet" href="../assets/prototype.css" />
</head>

@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>沪苏协同新兴产业科创大赛报名系统</title>
<link rel="icon" type="image/png" href="/favicon.png?v=20260829c">
<link rel="stylesheet" href="../assets/bootstrap.min.css" />
<link rel="stylesheet" href="../assets/prototype.css" />
</head>

@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>沪苏协同新兴产业科创大赛报名系统</title>
<link rel="icon" type="image/png" href="/favicon.png?v=20260829c">
<link rel="stylesheet" href="../assets/bootstrap.min.css" />
<link rel="stylesheet" href="../assets/prototype.css" />
</head>

@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>“光联万物 创芯未来”首届沪苏协同新兴产业青年科创大赛报名系统</title>
<link rel="icon" type="image/png" href="/favicon.png?v=20260829c">
<link rel="stylesheet" href="../assets/bootstrap.min.css" />
<link rel="stylesheet" href="../assets/prototype.css" />
</head>

@ -2,7 +2,8 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png?v=20260829c" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>沪苏协同新兴产业科创大赛报名系统</title>
</head>

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

@ -26,6 +26,7 @@ export default {
networkError: 'Network error. Unable to verify the competition link.',
networkLogin: 'Network error. Please confirm the server is running.',
requestFailed: 'Request failed',
tooFrequent: 'Please wait {n}s before requesting another code.',
sendNetwork: 'Network error. Please confirm the backend is running.',
},
layout: {
@ -75,6 +76,8 @@ export default {
pleaseSelect: 'Please select',
invalidEmail: 'Please enter a valid email address',
invalidMobile: 'Please enter a valid 11-digit Chinese mobile number',
maxChars: '{label} must be at most {n} characters',
fieldInvalid: '{label} is invalid',
opFailed: 'Operation failed',
notice: 'Notice',
pledgeTitle: 'Competition Declaration',
@ -99,6 +102,7 @@ export default {
competitionLoadFailed: 'Unable to load competition settings',
networkLoad: 'Network error. Unable to load the competition.',
draftFailed: 'The draft could not be saved. Check your network and try again.',
saveInvalid: 'Unable to save. Please check the form fields.',
saveFailed: 'Save failed',
saveTimeout: 'Save timed out. Please try again.',
submitNotFound: 'Competition not found or unpublished. Please re-enter the form.',

@ -26,6 +26,7 @@ export default {
networkError: '网络错误,无法校验赛事链接',
networkLogin: '网络错误,请确认后端已启动',
requestFailed: '请求失败',
tooFrequent: '请勿频繁发送,请 {n} 秒后再试',
sendNetwork:
'网络错误,请确认后端已启动(Vite 将 /api 代理至 php artisan serve 端口,见 vite.config)',
},
@ -76,6 +77,8 @@ export default {
pleaseSelect: '请选择',
invalidEmail: '请输入正确的邮箱格式',
invalidMobile: '请输入正确的11位中国大陆手机号',
maxChars: '{label}不能超过 {n} 个字符',
fieldInvalid: '{label}填写有误',
opFailed: '操作失败',
notice: '提示',
pledgeTitle: '赛事承诺书',
@ -100,6 +103,7 @@ export default {
competitionLoadFailed: '无法加载赛事配置',
networkLoad: '网络错误,无法加载赛事',
draftFailed: '草稿未能保存,请检查网络或稍后重试。',
saveInvalid: '保存失败,请检查填写内容。',
saveFailed: '保存失败',
saveTimeout: '保存超时,请稍后重试',
submitNotFound: '赛事不存在或未发布,请返回重新进入报名页。',

@ -45,6 +45,7 @@ const slug = computed(() => String(route.params.slug ?? '').trim())
const maxFileSize = 20 * 1024 * 1024
const MOBILE_PATTERN = '^1[3-9]\\d{9}$'
const EMAIL_LOOSE_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
interface FileItem {
id: string | number
@ -266,6 +267,15 @@ const brand = ref<BrandingForm>(emptyBrandingForm())
const loadError = ref('')
const formModel = reactive<Record<string, string>>({})
const formRevision = ref(0)
let applyingServerPayload = false
watch(
formModel,
() => {
if (!applyingServerPayload) formRevision.value += 1
},
{ deep: true, flush: 'sync' },
)
function fieldLabel(field: SignupFormSchemaField): string {
return localizedFieldText(field, portalLocale.value, 'label') || field.label
@ -581,8 +591,23 @@ function showNotice(message: string, title?: string, type: 'success' | 'warning'
resolve()
return
}
el.addEventListener('hidden.bs.modal', () => resolve(), { once: true })
Modal.getOrCreateInstance(el).show()
const inst = Modal.getOrCreateInstance(el)
const open = () => {
el.addEventListener('hidden.bs.modal', () => resolve(), { once: true })
inst.show()
}
if (el.classList.contains('show')) {
el.addEventListener(
'hidden.bs.modal',
() => {
window.setTimeout(open, 50)
},
{ once: true },
)
inst.hide()
return
}
open()
})
})
}
@ -1315,6 +1340,8 @@ function applyServerPayload(d: {
submitted_at?: string | null
files?: { id: number; kind: string; original_name: string; size: number; url: string }[]
} & Record<string, unknown>) {
applyingServerPayload = true
try {
applicationStatus.value = (d.status as 'draft' | 'submitted') || 'draft'
submittedAt.value = typeof d.submitted_at === 'string' ? d.submitted_at : ''
participantMayEdit.value = d.participant_may_edit !== false
@ -1377,6 +1404,9 @@ function applyServerPayload(d: {
})
validatePlanFile()
validateSupportingFiles()
} finally {
applyingServerPayload = false
}
}
async function loadApplicationFromServer() {
@ -1418,6 +1448,7 @@ async function loadApplicationFromServer() {
async function saveDraftToServer() {
if (savingDraft.value) return false
savingDraft.value = true
const epochAtStart = formRevision.value
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), 20000)
try {
@ -1432,13 +1463,20 @@ async function saveDraftToServer() {
return false
}
if (!r.ok) {
void r.json().catch(() => ({}))
showNotice(tx('apply.draftFailed'), tx('apply.saveFailed'), 'warning')
let body: Record<string, unknown> = {}
try {
body = (await r.json()) as Record<string, unknown>
} catch {
/* 非 JSON */
}
showNotice(saveFailureUserMessage(r.status, body), tx('apply.saveFailed'), 'warning')
return false
}
try {
const d = (await r.json()) as Parameters<typeof applyServerPayload>[0]
applyServerPayload(d)
if (formRevision.value === epochAtStart) {
applyServerPayload(d)
}
} catch {
/* 非 JSON 时保持本地状态 */
}
@ -1453,22 +1491,6 @@ async function saveDraftToServer() {
}
}
/** Laravel ValidationException:errors 为字段 → 字符串数组 */
function firstMessagesFromLaravelErrors(errors: unknown): string[] {
if (errors == null || typeof errors !== 'object' || Array.isArray(errors)) return []
const out: string[] = []
for (const v of Object.values(errors as Record<string, unknown>)) {
if (Array.isArray(v)) {
for (const s of v) {
if (typeof s === 'string' && s.trim()) out.push(s.trim())
}
} else if (typeof v === 'string' && v.trim()) {
out.push(v.trim())
}
}
return out
}
function isGenericLaravelInvalidMessage(message: string): boolean {
const m = message.trim()
return (
@ -1479,16 +1501,72 @@ function isGenericLaravelInvalidMessage(message: string): boolean {
)
}
/** 按接口返回组装选手可见的提交失败说明(优先业务 errors / message,再按状态码兜底) */
function submitFailureUserMessage(status: number, body: Record<string, unknown>): string {
const fromFields = firstMessagesFromLaravelErrors(body.errors)
if (fromFields.length > 0) return fromFields[0]
function signupFieldLabel(key: string): string {
const bare = key.includes('.') ? key.slice(key.lastIndexOf('.') + 1) : key
const field = schemaFields.value.find((f) => f.key === key || f.key === bare)
if (field) return fieldLabel(field)
if (bare === 'promise_signature' || bare === 'commitment_accepted') return tx('apply.pledgeTitle')
return bare
}
function humanizeSignupFieldError(key: string, message: string): string {
const label = signupFieldLabel(key)
const m = message.trim()
const lower = m.toLowerCase()
if (key === 'contact_email' || key.endsWith('.contact_email') || /valid email/.test(lower)) {
if (/required|必填|不能为空/.test(lower) || /required/.test(lower)) {
return tx('apply.pleaseFill', { label })
}
return tx('apply.invalidEmail')
}
if (key === 'contact_mobile' || key.endsWith('.contact_mobile')) {
if (/required|必填|不能为空/.test(lower)) return tx('apply.pleaseFill', { label })
return tx('apply.invalidMobile')
}
if (/required|必填|不能为空/.test(lower)) return tx('apply.pleaseFill', { label })
if (/the selected .+ is invalid|invalid/.test(lower) && /selected|in /.test(lower)) {
return tx('apply.pleaseSelectField', { label })
}
const max = m.match(/may not be greater than (\d+)/i) || m.match(/不能大于\s*(\d+)/)
if (max) return tx('apply.maxChars', { label, n: max[1] })
if (/[\u4e00-\u9fff]/.test(m)) return m
return tx('apply.fieldInvalid', { label })
}
function signupValidationUserMessages(errors: unknown): string[] {
if (errors == null || typeof errors !== 'object' || Array.isArray(errors)) return []
const rec = errors as Record<string, unknown>
const out: string[] = []
const seen = new Set<string>()
for (const [key, value] of Object.entries(rec)) {
const parts = Array.isArray(value) ? value : [value]
for (const part of parts) {
if (typeof part !== 'string' || !part.trim()) continue
const msg = humanizeSignupFieldError(key, part.trim())
if (seen.has(msg)) continue
seen.add(msg)
out.push(msg)
}
}
return out
}
function mappedOrLaravelValidationMessage(body: Record<string, unknown>): string | null {
const mapped = signupValidationUserMessages(body.errors)
if (mapped.length > 0) return mapped.join('\n')
const rawMsg = body.message
if (typeof rawMsg === 'string' && rawMsg.trim()) {
const msg = rawMsg.trim()
if (!isGenericLaravelInvalidMessage(msg)) return msg
}
return null
}
/** 按接口返回组装选手可见的提交失败说明(优先业务 errors / message,再按状态码兜底) */
function submitFailureUserMessage(status: number, body: Record<string, unknown>): string {
const specific = mappedOrLaravelValidationMessage(body)
if (specific) return specific
if (status === 404) return tx('apply.submitNotFound')
if (status === 403) return tx('apply.submitForbidden')
@ -1499,6 +1577,19 @@ function submitFailureUserMessage(status: number, body: Record<string, unknown>)
return tx('apply.submitFailed')
}
/** 暂存失败:校验错误展示字段原因,网络类问题才用草稿未能保存 */
function saveFailureUserMessage(status: number, body: Record<string, unknown>): string {
const specific = mappedOrLaravelValidationMessage(body)
if (specific) return specific
if (status === 404) return tx('apply.competitionMissing')
if (status === 403) return tx('apply.windowClosed')
if (status === 429) return tx('apply.submitBusy')
if (status >= 500) return tx('apply.submitServer')
if (status === 422) return tx('apply.saveInvalid')
return tx('apply.draftFailed')
}
async function submitApplicationToServer() {
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), 30000)
@ -1549,11 +1640,49 @@ async function submitApplicationToServer() {
}
}
function syncFormModelFromDom() {
const form = applyFormEl.value
if (!form) return
for (const field of schemaFields.value) {
if (field.type === 'file' || field.type === 'checkbox') continue
const el = form.elements.namedItem(field.key)
if (!el || el instanceof RadioNodeList) continue
if (el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement) {
formModel[field.key] = el.value
}
}
const other = form.elements.namedItem(TRACK_OTHER_FIELD_KEY)
if (other instanceof HTMLInputElement || other instanceof HTMLTextAreaElement) {
formModel[TRACK_OTHER_FIELD_KEY] = other.value
}
}
function validateDraftFormats(): boolean {
const problems: string[] = []
for (const field of schemaFields.value) {
if (!isFieldVisible(field) || field.type === 'file' || field.type === 'checkbox') continue
const v = String(formModel[field.key] ?? '').trim()
if (!v) continue
if ((field.type === 'email' || field.key === 'contact_email') && !EMAIL_LOOSE_RE.test(v)) {
problems.push(tx('apply.invalidEmail'))
}
if ((field.type === 'tel' || field.key === 'contact_mobile') && !/^1[3-9]\d{9}$/.test(v)) {
problems.push(tx('apply.invalidMobile'))
}
}
if (problems.length === 0) return true
wasValidated.value = true
showNotice([...new Set(problems)].join('\n'), tx('apply.saveFailed'), 'warning')
return false
}
async function onSaveClick() {
if (!localStorage.getItem(TOKEN_KEY)) {
goLogin()
return
}
syncFormModelFromDom()
if (!validateDraftFormats()) return
const ok = await saveDraftToServer()
if (!ok) return
saveDraftLocal()
@ -1565,6 +1694,7 @@ async function onSubmitClick() {
goLogin()
return
}
syncFormModelFromDom()
if (!validateForm()) return
const result = await submitApplicationToServer()
if (!result) return
@ -1657,6 +1787,7 @@ onMounted(() => {
ref="applyFormEl"
class="row g-3"
:class="{ 'was-validated': wasValidated }"
novalidate
@submit.prevent
>
<template v-for="field in orderedSignupFields" :key="field.type === 'file' ? 'f-' + field.key : field.key">
@ -1673,6 +1804,7 @@ onMounted(() => {
<div class="track-custom-select w-100">
<select
id="track"
name="track"
ref="trackHiddenSelect"
v-model="formModel.track"
class="visually-hidden"
@ -1724,6 +1856,7 @@ onMounted(() => {
<input
v-if="isOtherTrack(formModel.track)"
:id="TRACK_OTHER_FIELD_KEY"
:name="TRACK_OTHER_FIELD_KEY"
v-model="formModel[TRACK_OTHER_FIELD_KEY]"
class="form-control editable mt-2"
type="text"
@ -1915,6 +2048,7 @@ onMounted(() => {
>
</label>
<select
:name="field.key"
v-model="formModel[field.key]"
class="form-select editable"
:required="effectiveRequired(field)"
@ -1944,6 +2078,7 @@ onMounted(() => {
>
</label>
<select
name="location_city"
v-model="formModel.location_city"
class="form-select editable"
:required="effectiveRequired(field)"
@ -2039,6 +2174,7 @@ onMounted(() => {
>
</label>
<textarea
:name="field.key"
v-model="formModel[field.key]"
class="form-control editable"
rows="4"
@ -2078,13 +2214,15 @@ onMounted(() => {
>
</label>
<input
:name="field.key"
v-model="formModel[field.key]"
:type="
field.type === 'email'
:type="field.key === 'contact_mobile' ? 'tel' : 'text'"
:inputmode="
field.type === 'email' || field.key === 'contact_email'
? 'email'
: field.key === 'contact_mobile'
? 'tel'
: 'text'
: undefined
"
class="form-control editable"
:class="{ 'company-name-input': field.key === 'company_name' }"
@ -2092,7 +2230,13 @@ onMounted(() => {
:disabled="formDisabled"
:placeholder="fieldPlaceholderText(field)"
:pattern="field.key === 'contact_mobile' ? MOBILE_PATTERN : undefined"
:autocomplete="field.key === 'company_name' ? 'organization' : undefined"
:autocomplete="
field.key === 'company_name'
? 'organization'
: field.type === 'email' || field.key === 'contact_email'
? 'email'
: undefined
"
/>
<div class="invalid-feedback">{{ nativeInputInvalidFeedback(field) }}</div>
<small

@ -90,10 +90,10 @@ function clearCodeCooldown() {
}
}
/** 发送成功后倒计时,与后端重发间隔一致(见 getSmsResendCooldownSeconds);仅当前登录页有效 */
function startSmsResendCooldown() {
/** 发送成功或被限流后倒计时,与后端重发间隔一致(见 getSmsResendCooldownSeconds);仅当前登录页有效 */
function startSmsResendCooldown(seconds?: number) {
clearCodeCooldown()
const total = getSmsResendCooldownSeconds()
const total = Math.max(1, Math.floor(seconds ?? getSmsResendCooldownSeconds()))
let left = total
codeDisabled.value = true
codeLabel.value = t('login.resend', { n: left })
@ -109,6 +109,49 @@ function startSmsResendCooldown() {
}, 1000)
}
function collectErrorMessages(data: unknown): string[] {
if (data == null || typeof data !== 'object') return []
const d = data as Record<string, unknown>
const out: string[] = []
if (typeof d.message === 'string' && d.message.trim()) out.push(d.message.trim())
const errors = d.errors
if (errors != null && typeof errors === 'object' && !Array.isArray(errors)) {
for (const v of Object.values(errors as Record<string, unknown>)) {
if (Array.isArray(v)) {
for (const item of v) {
if (typeof item === 'string' && item.trim()) out.push(item.trim())
}
} else if (typeof v === 'string' && v.trim()) {
out.push(v.trim())
}
}
}
return out
}
function parseSmsRetryAfter(data: unknown): number | null {
if (data == null || typeof data !== 'object') return null
const d = data as Record<string, unknown>
const raw = d.retry_after
const fromField =
typeof raw === 'number'
? raw
: typeof raw === 'string' && /^\d+$/.test(raw.trim())
? parseInt(raw.trim(), 10)
: NaN
if (Number.isFinite(fromField) && fromField >= 1) return Math.min(600, Math.floor(fromField))
const messages = collectErrorMessages(data)
const frequent = messages.find((msg) => msg.includes('请勿频繁发送') || /too frequent|try again later/i.test(msg))
if (!frequent) return null
const matched = frequent.match(/(\d+)\s*秒/) || frequent.match(/(\d+)\s*s\b/i)
if (matched) {
const n = parseInt(matched[1], 10)
if (Number.isFinite(n) && n >= 1) return Math.min(600, n)
}
return getSmsResendCooldownSeconds()
}
function apiBase() {
return getApiBase()
}
@ -152,7 +195,6 @@ function parseJsonErrorPayload(data: unknown): string {
return t('login.requestFailed')
}
let faviconHrefBackup: string | null = null
let loginHtmlClassApplied = false
async function loadCompetitionHint() {
@ -187,18 +229,15 @@ async function loadCompetitionHint() {
applyParticipantBodyTheme(brand.value.login.themePrimary)
const fav = brand.value.login.faviconUrl
if (hasVisibleText(fav) && typeof document !== 'undefined') {
if (typeof document !== 'undefined') {
let link = document.querySelector('link[rel~="icon"]') as HTMLLinkElement | null
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
if (faviconHrefBackup === null) {
faviconHrefBackup = link.getAttribute('href') || ''
}
link.href = fav
link.type = 'image/png'
link.href = '/favicon.png?v=20260829c'
}
} catch {
competitionLoadError.value = t('login.networkError')
@ -238,11 +277,17 @@ async function sendCode() {
})
const data: unknown = await r.json().catch(() => ({}))
if (!r.ok) {
debugSmsCode.value = ''
const retryAfter = parseSmsRetryAfter(data)
if (retryAfter != null) {
setHint(t('login.tooFrequent', { n: retryAfter }), 'error')
startSmsResendCooldown(retryAfter)
return
}
setHint(parseJsonErrorPayload(data), 'error')
codeLabel.value = t('login.getCode')
codeDisabled.value = false
clearCodeCooldown()
debugSmsCode.value = ''
return
}
const payload = data as { debug_code?: unknown }
@ -380,14 +425,6 @@ onUnmounted(() => {
clearCodeCooldown()
if (typeof document !== 'undefined') {
clearParticipantBodyTheme()
if (faviconHrefBackup !== null) {
const link = document.querySelector('link[rel~="icon"]') as HTMLLinkElement | null
if (link) {
if (faviconHrefBackup === '') link.removeAttribute('href')
else link.href = faviconHrefBackup
}
faviconHrefBackup = null
}
}
})
</script>

@ -57,7 +57,6 @@ const footerCopyrightText = computed(() =>
hasVisibleText(brand.value.login.footerCopyright) ? brand.value.login.footerCopyright : '',
)
let faviconHrefBackup: string | null = null
let loginHtmlClassApplied = false
function setHint(message: string, tone: 'neutral' | 'error' | 'success' = 'neutral') {
@ -110,18 +109,15 @@ async function loadCompetitionBrand() {
applyParticipantBodyTheme(brand.value.login.themePrimary)
const fav = brand.value.login.faviconUrl
if (hasVisibleText(fav) && typeof document !== 'undefined') {
if (typeof document !== 'undefined') {
let link = document.querySelector('link[rel~="icon"]') as HTMLLinkElement | null
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
if (faviconHrefBackup === null) {
faviconHrefBackup = link.getAttribute('href') || ''
}
link.href = fav
link.type = 'image/png'
link.href = '/favicon.png?v=20260829c'
}
} catch {
competitionLoadError.value = '网络错误,无法校验赛事链接'
@ -206,14 +202,6 @@ onUnmounted(() => {
document.body.classList.remove('login-page-wls')
if (typeof document !== 'undefined') {
clearParticipantBodyTheme()
if (faviconHrefBackup !== null) {
const link = document.querySelector('link[rel~="icon"]') as HTMLLinkElement | null
if (link) {
if (faviconHrefBackup === '') link.removeAttribute('href')
else link.href = faviconHrefBackup
}
faviconHrefBackup = null
}
}
})
</script>

@ -119,62 +119,17 @@ const loadError = ref('')
const pledgeSignedDateCn = computed(() => formatPledgeCnDate(detail.value?.promise_signed_at))
/** 项目总分草稿:始终用字符串,避免 number 输入把值变成 number 后 .trim 崩溃导致弹窗只剩遮罩 */
const totalDraft = ref('')
const scoreSubmitting = ref(false)
const totalBlurred = ref(false)
const scoreShowErrors = ref(false)
const fullScore = computed(() => Number(detail.value?.scoring_sheet?.full_score ?? 100) || 100)
function totalDraftText(): string {
return String(totalDraft.value ?? '').trim()
}
const SCORE_MAX = 100
const SCORE_DECIMALS = 2
/** 文本输入绑定:保留用户输入以便校验提示,不做自动改写 */
const totalDraftModel = computed({
get: () => totalDraft.value,
set: (v: string | number | null | undefined) => {
const raw = v == null ? '' : String(v)
// 仅过滤非法字符;允许负号、大于 100、超过两位小数,由校验提示拦截
let cleaned = raw.replace(/[^\d.-]/g, '')
cleaned = cleaned.replace(/(?!^)-/g, '')
cleaned = cleaned.replace(/(\..*)\./g, '$1')
totalDraft.value = cleaned
},
})
const itemDrafts = ref<Record<string, string>>({})
const itemBlurred = ref<Record<string, boolean>>({})
const scoreSubmitting = ref(false)
const scoreShowErrors = ref(false)
function syncScoreDraft(): void {
const d = detail.value
if (!d?.scoring_sheet?.items?.length) {
totalDraft.value = ''
totalBlurred.value = false
scoreShowErrors.value = false
return
}
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(SCORE_DECIMALS)) : ''
}
totalBlurred.value = false
scoreShowErrors.value = false
}
const fullScore = computed(() => Number(detail.value?.scoring_sheet?.full_score ?? 100) || 100)
watch(detail, () => syncScoreDraft(), { deep: true })
const scoringItems = computed(() => detail.value?.scoring_sheet?.items ?? [])
function rtrimZeros(s: string): string {
return s.replace(/\.?0+$/, '')
@ -188,7 +143,42 @@ function hasMoreThanTwoDecimals(raw: string): boolean {
return /\.\d{3,}/.test(raw)
}
const scoringItems = computed(() => detail.value?.scoring_sheet?.items ?? [])
function sanitizeScoreInput(v: string | number | null | undefined): string {
const raw = v == null ? '' : String(v)
let cleaned = raw.replace(/[^\d.]/g, '')
cleaned = cleaned.replace(/(\..*)\./g, '$1')
const dot = cleaned.indexOf('.')
if (dot >= 0) {
cleaned = cleaned.slice(0, dot + 1) + cleaned.slice(dot + 1).replace(/\./g, '').slice(0, SCORE_DECIMALS)
}
return cleaned
}
function setItemDraft(key: string, v: string | number | null | undefined) {
itemDrafts.value = { ...itemDrafts.value, [key]: sanitizeScoreInput(v) }
}
function syncScoreDraft(): void {
const next: Record<string, string> = {}
for (const item of scoringItems.value) {
next[item.key] = ''
}
const scores = detail.value?.my_review_score?.payload_json?.scores
if (scores && typeof scores === 'object' && !Array.isArray(scores)) {
const rec = scores as Record<string, unknown>
for (const item of scoringItems.value) {
const raw = rec[item.key]
if (raw == null || String(raw).trim() === '') continue
const n = Number(raw)
next[item.key] = Number.isFinite(n) ? rtrimZeros(n.toFixed(SCORE_DECIMALS)) : ''
}
}
itemDrafts.value = next
itemBlurred.value = {}
scoreShowErrors.value = false
}
watch(detail, () => syncScoreDraft(), { deep: true })
/** 合并相同「指标分类」单元格 */
const scoringRows = computed(() => {
@ -217,36 +207,64 @@ const scoringRows = computed(() => {
return rows
})
function getTotalError(): string | null {
const raw = totalDraftText()
function getItemError(item: ScoringSheetItem): string | null {
const raw = String(itemDrafts.value[item.key] ?? '').trim()
const show = scoreShowErrors.value || Boolean(itemBlurred.value[item.key])
if (raw === '') {
if (scoreShowErrors.value || totalBlurred.value) return '请填写项目总分'
return null
return show ? `请填写「${item.title}」得分` : null
}
if (raw === '-' || raw === '.' || raw === '-.') return '须为数字'
if (hasMoreThanTwoDecimals(raw)) return '项目总分最多保留两位小数'
if (hasMoreThanTwoDecimals(raw)) return '最多保留两位小数'
const n = Number(raw)
if (!Number.isFinite(n)) return '须为数字'
if (n < 0) return '项目总分不能为负数'
if (n > SCORE_MAX) return '项目总分不能大于 100'
if (n < 0) return '不能小于 0'
if (n > Number(item.max_score)) return `不能高于该项分值 ${formatMax(item.max_score)}`
return null
}
function onTotalBlur(): void {
totalBlurred.value = true
const computedTotal = computed((): number | null => {
if (!scoringItems.value.length) return null
let sum = 0
for (const item of scoringItems.value) {
const raw = String(itemDrafts.value[item.key] ?? '').trim()
if (raw === '' || getItemError(item)) return null
const n = Number(raw)
if (!Number.isFinite(n)) return null
sum += n
}
return Math.round(sum * 100) / 100
})
function getTotalError(): string | null {
if (!scoringItems.value.length) return null
const unfinished = scoringItems.value.some((item) => {
const raw = String(itemDrafts.value[item.key] ?? '').trim()
return raw === '' || getItemError(item) !== null
})
if (unfinished) {
return scoreShowErrors.value ? '请为每一项填写有效得分' : null
}
const n = computedTotal.value
if (n == null) return scoreShowErrors.value ? '请为每一项填写有效得分' : null
if (n < 0) return '项目总分不能小于 0'
if (n > SCORE_MAX) return '项目总分不能大于 100'
return null
}
const canSubmitScore = computed(() => {
if (!detail.value?.scoring_allowed || !scoringItems.value.length) return false
return getTotalError() === null && totalDraftText() !== ''
if (computedTotal.value == null) return false
return scoringItems.value.every((item) => getItemError(item) === null) && getTotalError() === null
})
const scoreFormHint = computed(() => {
if (!scoringItems.value.length) return ''
const firstItemErr = scoringItems.value.map((item) => getItemError(item)).find((err) => err)
if (firstItemErr && (scoreShowErrors.value || Object.values(itemBlurred.value).some(Boolean))) {
return firstItemErr
}
const err = getTotalError()
return err && (scoreShowErrors.value || totalBlurred.value || totalDraftText() !== '')
? err
: ''
return err && scoreShowErrors.value ? err : ''
})
const statusBadgeText = computed(() => {
@ -379,9 +397,11 @@ async function downloadAttachment(f: FileItem) {
}
function buildSubmitPayload(): Record<string, unknown> {
// 仅在 canSubmitScore 通过后调用;不做四舍五入/截断,原样提交校验过的数值
const n = Number(totalDraftText())
return { line_total: n }
const scores: Record<string, number> = {}
for (const item of scoringItems.value) {
scores[item.key] = Number(String(itemDrafts.value[item.key] ?? '').trim())
}
return { scores }
}
function cleanupModalArtifacts(): void {
@ -426,7 +446,6 @@ function openScoreModal(): void {
return
}
scoreShowErrors.value = false
totalBlurred.value = false
cleanupModalArtifacts()
if (typeof document === 'undefined') return
const el = document.getElementById('reviewerScoreModal')
@ -448,7 +467,7 @@ async function submitReviewScore() {
}
if (!canSubmitScore.value) {
scoreShowErrors.value = true
ElMessage.warning(getTotalError() || '请填写 0~100 之间的项目总分')
ElMessage.warning(getTotalError() || '请为每一项填写 0~该项分值的得分')
return
}
@ -674,7 +693,7 @@ async function submitReviewScore() {
</div>
<div class="modal-body">
<p class="small text-secondary mb-2 text-center scoring-modal-hint">
下表为评审参考;请在底部填写项目总分(0~100,最多两位小数,可为 0)。
请为每一项打分(0~该项分值,最多两位小数)。项目总分为各项得分之和,无需填写。
</p>
<p v-if="scoreFormHint" class="small text-danger mb-2 text-center" role="alert">{{ scoreFormHint }}</p>
<div class="table-responsive scoring-sheet-wrap">
@ -685,6 +704,7 @@ async function submitReviewScore() {
<col class="col-indicator" />
<col class="col-criteria" />
<col class="col-max" />
<col class="col-score" />
</colgroup>
<thead>
<tr>
@ -693,6 +713,7 @@ 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>
@ -708,26 +729,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">
<input
class="form-control form-control-sm text-center scoring-item-input"
:class="{ 'is-invalid': !!getItemError(row.item) }"
type="text"
inputmode="decimal"
autocomplete="off"
:aria-label="`${row.item.title}得分`"
:value="itemDrafts[row.item.key] ?? ''"
@input="setItemDraft(row.item.key, ($event.target as HTMLInputElement).value)"
@blur="itemBlurred = { ...itemBlurred, [row.item.key]: true }"
/>
<div v-if="getItemError(row.item)" class="invalid-feedback d-block">{{ getItemError(row.item) }}</div>
</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="form-label mb-0 fw-semibold">项目总分</span>
<strong class="scoring-total-value">{{
computedTotal == null ? '—' : rtrimZeros(computedTotal.toFixed(2))
}}</strong>
<span class="scoring-total-full small text-secondary">满分 {{ formatMax(fullScore) }}</span>
</div>
<div v-if="getTotalError()" class="invalid-feedback d-block">{{ getTotalError() }}</div>
@ -844,7 +869,7 @@ async function submitReviewScore() {
table-layout: fixed;
width: 100%;
/* 窄屏保底宽度,横向滚动;宽屏由 100% 撑满弹窗 */
min-width: 52rem;
min-width: 58rem;
margin-bottom: 0;
font-size: 0.875rem;
}
@ -869,6 +894,10 @@ async function submitReviewScore() {
width: 5rem;
}
.scoring-sheet-table .col-score {
width: 8rem;
}
.scoring-modal-hint {
flex: 0 0 auto;
}
@ -889,12 +918,20 @@ async function submitReviewScore() {
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-total-value {
font-size: 1.35rem;
font-weight: 700;
color: #163f56;
min-width: 4.5rem;
}
.scoring-item-input {
min-width: 4.5rem;
}
.scoring-score-cell {
vertical-align: middle;
min-width: 7rem;
}
.scoring-total-full {

@ -73,7 +73,6 @@ const footerCopyrightText = computed(() =>
hasVisibleText(brand.value.login.footerCopyright) ? brand.value.login.footerCopyright : '',
)
let faviconHrefBackup: string | null = null
let loginHtmlClassApplied = false
function apiBase() {
@ -174,18 +173,15 @@ async function loadCompetitionBrand() {
applyParticipantBodyTheme(brand.value.login.themePrimary)
const fav = brand.value.login.faviconUrl
if (hasVisibleText(fav) && typeof document !== 'undefined') {
if (typeof document !== 'undefined') {
let link = document.querySelector('link[rel~="icon"]') as HTMLLinkElement | null
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
if (faviconHrefBackup === null) {
faviconHrefBackup = link.getAttribute('href') || ''
}
link.href = fav
link.type = 'image/png'
link.href = '/favicon.png?v=20260829c'
}
} catch {
competitionLoadError.value = '网络错误,无法校验赛事链接'
@ -287,14 +283,6 @@ onUnmounted(() => {
document.body.classList.remove('login-page-wls')
if (typeof document !== 'undefined') {
clearParticipantBodyTheme()
if (faviconHrefBackup !== null) {
const link = document.querySelector('link[rel~="icon"]') as HTMLLinkElement | null
if (link) {
if (faviconHrefBackup === '') link.removeAttribute('href')
else link.href = faviconHrefBackup
}
faviconHrefBackup = null
}
}
})
</script>

@ -14,7 +14,6 @@ import {
resolveListPageState,
saveListPageState,
} from '../../utils/listPageState'
import { EVENT_LOCATIONS } from '../../utils/eventLocations'
export interface ReviewApplicationRow {
id: number
@ -44,12 +43,9 @@ const resultHint = ref('')
const filters = reactive({
keyword: '',
event_location: '' as string,
review_status: '' as '' | 'pending' | 'reviewed',
})
const eventLocationOptions = EVENT_LOCATIONS
const meta = ref({
current_page: 1,
last_page: 1,
@ -66,7 +62,6 @@ function listStateStorageKey(): string {
function currentListFilters(): Record<string, string> {
const f: Record<string, string> = {}
if (filters.event_location) f.event_location = filters.event_location
if (filters.keyword.trim()) f.keyword = filters.keyword.trim()
if (filters.review_status) f.review_status = filters.review_status
return f
@ -95,7 +90,6 @@ function applyListState(): void {
const next = resolveListPageState(listStateStorageKey(), route.query)
page.value = next.page
perPage.value = next.perPage
filters.event_location = next.filters.event_location ?? ''
filters.keyword = next.filters.keyword ?? ''
filters.review_status =
next.filters.review_status === 'pending' || next.filters.review_status === 'reviewed'
@ -121,7 +115,6 @@ function buildListParams(): URLSearchParams {
qs.set('per_page', String(perPage.value))
const kw = filters.keyword.trim()
if (kw) qs.set('keyword', kw)
if (filters.event_location) qs.set('event_location', filters.event_location)
if (filters.review_status === 'pending' || filters.review_status === 'reviewed') {
qs.set('review_status', filters.review_status)
}
@ -184,7 +177,6 @@ function updateResultHint() {
return
}
const parts: string[] = [`共 ${t} 条`]
if (filters.event_location) parts.push(filters.event_location)
const kw = filters.keyword.trim()
if (kw) parts.push(`关键词「${kw}」`)
if (filters.review_status === 'pending') parts.push('待评审')
@ -205,7 +197,6 @@ function refresh(p = page.value) {
function resetFilters() {
filters.keyword = ''
filters.event_location = ''
filters.review_status = ''
refresh(1)
}
@ -250,37 +241,6 @@ function actionLabel(row: ReviewApplicationRow): string {
<div class="filter-panel mb-3">
<div class="row g-2 align-items-center">
<div class="col-12 d-flex flex-wrap align-items-center gap-3">
<span class="small text-secondary text-nowrap">意向参赛地点</span>
<div class="form-check form-check-inline mb-0">
<input
id="reviewer-event-location-all"
v-model="filters.event_location"
class="form-check-input"
type="radio"
name="reviewer-event-location"
value=""
@change="refresh(1)"
/>
<label class="form-check-label" for="reviewer-event-location-all">全部</label>
</div>
<div
v-for="g in eventLocationOptions"
:key="g"
class="form-check form-check-inline mb-0"
>
<input
:id="`reviewer-event-location-${g}`"
v-model="filters.event_location"
class="form-check-input"
type="radio"
name="reviewer-event-location"
:value="g"
@change="refresh(1)"
/>
<label class="form-check-label" :for="`reviewer-event-location-${g}`">{{ g }}</label>
</div>
</div>
<div class="col-md-4">
<input
v-model="filters.keyword"

Loading…
Cancel
Save