style: 表单页面移动端

This commit is contained in:
2026-03-24 11:12:49 +08:00
parent 982b7308e8
commit 341cbf7eb1
8 changed files with 676 additions and 230 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 KiB

View File

@@ -220,6 +220,12 @@ export default {
checkYourEmail: 'Check your email',
enterSixDigitCode: 'Enter the 6-digit code sent to',
verify: 'Verify',
verifyCode: 'Verify Code',
verifyPlaceholder: 'enter 6-digital code',
stepTabVerify: 'Verify',
stepTabProfile: 'Profile',
stepTabDesign: 'Design Material',
stepTabTerms: 'Terms',
resendCode: 'Resend',
resendCodeIn: 'Resend Code in',
// 验证消息
@@ -251,6 +257,9 @@ export default {
uploadVideoOnly: 'Please upload a MP4 file only.',
fileSizeExceeds: 'File size exceeds {sizeLimit} limit. Please upload a smaller file.',
videoDurationExceeds: 'Video duration exceeds 60 seconds limit. Please upload a shorter video.',
uploadFailed: 'Upload failed'
uploadFailed: 'Upload failed',
nextStep: 'Next Step',
stepTips: 'Please complete this form in one sitting.',
backToIntroduction: 'Back to Introduction'
}
}

View File

@@ -209,6 +209,12 @@ export default {
checkYourEmail: '请查看您的邮箱',
enterSixDigitCode: '请输入发送到邮箱的 6 位验证码',
verify: '验证',
verifyCode: '验证码',
verifyPlaceholder: '输入6位数字的验证码',
stepTabVerify: '验证',
stepTabProfile: '资料',
stepTabDesign: '设计材料',
stepTabTerms: '条款',
resendCode: '重新发送',
resendCodeIn: '重新发送',
// 验证消息
@@ -240,6 +246,9 @@ export default {
uploadVideoOnly: '请仅上传 MP4 文件。',
fileSizeExceeds: '文件大小超过 {sizeLimit} 限制。请上传较小的文件。',
videoDurationExceeds: '视频时长不可超过60秒',
uploadFailed: '上传失败'
uploadFailed: '上传失败',
nextStep: '下一步',
stepTips: '请一次性完成这个表单。',
backToIntroduction: '返回赛事介绍'
}
}

View File

@@ -6,25 +6,22 @@ export const useIsMobile = () => {
let resizeTimer: ReturnType<typeof setTimeout> | null = null
const checkDevice = () => {
// 使用防抖避免频繁触发
if (resizeTimer) {
clearTimeout(resizeTimer)
}
resizeTimer = setTimeout(() => {
// 1. 现代 Client Hints APIChrome/Edge 最准)
// if (navigator.userAgentData?.mobile !== undefined) {
// isMobile.value = navigator.userAgentData.mobile
// console.log('使用 userAgentData:', isMobile.value)
// }
// 2. 综合判断(兼容所有浏览器)
const ua = navigator.userAgent.toLowerCase()
const mobileRegex = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i
const mobileRegex = /android|webos|iphone|ipod|blackberry|iemobile|opera mini/i
const tabletRegex = /ipad|tablet|playbook|silk/i
const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 1
const smallScreen = window.innerWidth <= 1200 // 你可以改成 1024 等
const smallScreen = window.innerWidth <= 768
const tabletScreen = window.innerWidth > 768 && window.innerWidth <= 1200
const uaCheck = mobileRegex.test(ua)
isMobile.value = uaCheck || smallScreen
const uaMobile = mobileRegex.test(ua)
const uaTablet = tabletRegex.test(ua)
isMobile.value = (uaMobile && !uaTablet) || smallScreen
}, 100)
}
@@ -35,7 +32,7 @@ export const useIsMobile = () => {
onMounted(() => {
checkDevice()
window.addEventListener('resize', handleResize)
window.addEventListener('orientationchange', checkDevice) // 手机旋转必备
window.addEventListener('orientationchange', checkDevice)
})
onUnmounted(() => {
@@ -46,10 +43,55 @@ export const useIsMobile = () => {
}
})
// 处理 keep-alive 缓存的组件重新激活场景
onActivated(() => {
checkDevice()
})
return { isMobile }
}
export const useIsTablet = () => {
const isTablet = ref(false)
let resizeTimer: ReturnType<typeof setTimeout> | null = null
const checkDevice = () => {
if (resizeTimer) {
clearTimeout(resizeTimer)
}
resizeTimer = setTimeout(() => {
const ua = navigator.userAgent.toLowerCase()
const tabletRegex = /ipad|tablet|playbook|silk/i
const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0
const tabletScreen = window.innerWidth >= 769 && window.innerWidth <= 1200
const uaTablet = tabletRegex.test(ua)
isTablet.value = (uaTablet && tabletScreen) || (hasTouch && tabletScreen)
}, 100)
}
const handleResize = () => {
checkDevice()
}
onMounted(() => {
checkDevice()
window.addEventListener('resize', handleResize)
window.addEventListener('orientationchange', checkDevice)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
window.removeEventListener('orientationchange', checkDevice)
if (resizeTimer) {
clearTimeout(resizeTimer)
}
})
onActivated(() => {
checkDevice()
})
return { isTablet }
}

View File

@@ -0,0 +1,162 @@
<template>
<nav class="step-bar" role="tablist" :aria-label="t('AwardApply.applicationForm')">
<button
v-for="(key, index) in tabKeys"
:key="key"
type="button"
class="step-item"
:class="{ active: modelValue === index }"
role="tab"
:aria-selected="modelValue === index"
:tabindex="modelValue === index ? 0 : -1"
@click="onSelect(index)"
>
<div class="step-cluster">
<div class="step-head">
<span class="step-badge">{{ index + 1 }}</span>
<span class="step-label">{{ t(`AwardApply.${key}`) }}</span>
</div>
<div class="step-indicator" aria-hidden="true">
<span v-if="modelValue === index" class="step-indicator-bar" />
</div>
</div>
</button>
</nav>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const tabKeys = ['stepTabVerify', 'stepTabProfile', 'stepTabDesign', 'stepTabTerms'] as const
const props = withDefaults(
defineProps<{
modelValue?: number
}>(),
{ modelValue: 0 }
)
const emit = defineEmits<{
'update:modelValue': [index: number]
change: [index: number]
}>()
const { t } = useI18n()
function onSelect(index: number) {
if (props.modelValue === index) return
emit('update:modelValue', index)
emit('change', index)
}
</script>
<style scoped lang="less">
@active-red: #c7342c;
@label-active: #585858;
@label-inactive: #9e9e9e;
@badge-inactive-bg: #dcdcdc;
@badge-inactive-text: #9f9f9f;
@bar-height: 0.4rem;
.step-bar {
height: 100%;
display: flex;
align-items: stretch;
justify-content: space-between;
width: 100%;
background: #fff;
border-bottom: 1px solid #e5e5e5;
padding: 0 3rem;
box-sizing: border-box;
}
.step-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
margin: 0;
padding: 1.6rem 0.4rem 0;
border: none;
background: transparent;
cursor: pointer;
font: inherit;
color: inherit;
-webkit-tap-highlight-color: transparent;
&:focus-visible {
outline: 2px solid @active-red;
outline-offset: 2px;
}
}
.step-cluster {
display: inline-flex;
flex-direction: column;
align-items: center;
max-width: 100%;
}
.step-head {
display: inline-flex;
align-items: center;
gap: 1rem;
max-width: 100%;
}
.step-badge {
flex-shrink: 0;
width: 3.2rem;
height: 3.2rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: 'ArialBold';
font-weight: 700;
font-size: 1.8rem;
line-height: 1;
background: @badge-inactive-bg;
color: @badge-inactive-text;
transition: background 0.2s ease, color 0.2s ease;
}
.step-label {
font-family: 'Poppins';
font-weight: 400;
font-size: 1.6rem;
color: @label-inactive;
white-space: nowrap;
}
.step-item.active {
.step-badge {
background: @active-red;
color: #fff;
}
.step-label {
color: @label-active;
}
}
.step-indicator {
width: 120%;
min-height: @bar-height;
margin-top: 1.2rem;
display: flex;
justify-content: center;
align-items: flex-end;
}
/* 略宽于上方徽章+文案(与 step-head 同宽基准 + 左右扩展) */
.step-indicator-bar {
display: block;
height: @bar-height;
width: calc(100% + 1.2rem);
min-width: 0;
background: @active-red;
border-radius: 0.1rem 0.1rem 0 0;
}
</style>

View File

@@ -132,7 +132,12 @@ type BtnType = 'index' | 'form'
const btnType = ref<BtnType>('index')
const btnText = computed(() => {
if (isMobile.value) {
if (btnType.value === 'index') {
return t('AwardsPage.submitMobile')
}
if (btnType.value === 'form') {
return locale.value === 'CHINESE_SIMPLIFIED' ? '赛事介绍' : 'Back'
}
} else {
if (btnType.value === 'index') {
return t('AwardsPage.submitApplication')
@@ -262,6 +267,9 @@ const handleBtnClick = () => {
</script>
<style lang="less" scoped>
.award-container {
height: 100%;
}
.header-wrapper {
.header-placeholder {
height: 8rem;

View File

@@ -1,19 +1,51 @@
<template>
<div class="apply-container">
<div class="banner">
<div class="slogan">
<div class="apply-container" :class="{ mobile: isMobile }">
<div class="banner" :class="{ tablet: isTablet, mobile: isMobile && !isTablet }">
<div class="slogan" @click="handleTestDate">
{{ t('AwardApply.slogan') }}
</div>
<div class="title poppins-medium">{{ t('AwardApply.applicationForm') }}</div>
<div class="form-header" v-if="!isCompleted && !isExpired">
<div class="title poppins-medium" @click="handleTestComplete">
{{ t('AwardApply.applicationForm') }}
</div>
<div class="form-header" v-if="!isCompleted && !isExpired && !isMobile && !isTablet">
<div class="form-title poppins-bold">
{{ t('AwardApply.emailVerification') }}
</div>
<div class="desc">{{ t('AwardApply.aidaUsersOnly') }}</div>
</div>
<div class="steps-container" v-if="(isMobile || isTablet) && !isCompleted && !isExpired">
<Step v-model="step" />
</div>
</div>
<div
class="success-wrapper"
v-if="isCompleted || isExpired"
:class="{ mobile: isMobile, tablet: isTablet }"
>
<Success
class="success-container"
:class="{ mobile: isMobile, tablet: isTablet }"
:isExpired="isExpired"
/>
<div
class="step-btn back"
v-show="(isCompleted || isExpired) && (isMobile || isTablet)"
@click="handleBackToIntroduction"
>
{{ t('AwardApply.backToIntroduction') }}
</div>
</div>
<div
class="form-container"
v-if="!isCompleted && !isExpired"
:class="{ mobile: isMobile, tablet: isTablet }"
>
<div class="form-header" v-if="showStep(0)">
<div class="form-title poppins-bold">
{{ t('AwardApply.emailVerification') }}
</div>
<div class="desc">{{ t('AwardApply.aidaUsersOnly') }}</div>
</div>
<Success :isExpired="isExpired" v-if="isCompleted || isExpired" />
<div class="form-container" v-if="!isCompleted && !isExpired">
<div class="form-content">
<a-form
name="form"
@@ -23,7 +55,7 @@
:rules="rulesRef"
autocomplete="off"
>
<div class="email-box full-row">
<div class="email-box full-row" v-show="showStep(0)">
<a-form-item name="email" required :label="t('AwardApply.emailAddress')">
<div class="email-wrapper flex align-center">
<a-input v-model:value="form.email" />
@@ -47,13 +79,36 @@
</div>
</a-form-item>
</div>
<div class="form-row full-row">
<div
v-if="isMobile || isTablet"
class="email-box verify-box full-row"
v-show="showStep(0)"
>
<a-form-item name="verify" required :label="t('AwardApply.verifyCode')">
<div class="email-wrapper flex align-center">
<a-input
v-model:value="mobileVerifyCode"
:placeholder="t('AwardApply.verifyPlaceholder')"
/>
<div class="code-btn" @click="handleVerifyCode">
{{ t('AwardApply.verify') }}
</div>
<img
v-if="hasValidEmail"
src="@/assets/images/award/checked.png"
alt=""
class="checked-icon"
/>
</div>
</a-form-item>
</div>
<div v-show="showStep(1)" class="form-row full-row" :class="{ mobile: isMobile }">
<div class="form-title poppins-bold">
{{ t('AwardApply.personalInformation') }}
</div>
<div class="desc">{{ t('AwardApply.tellUsAboutYourself') }}</div>
</div>
<div class="user-info flex">
<div v-show="showStep(1)" class="user-info flex">
<template v-for="item in formKeys" :key="item.key">
<a-form-item
v-if="item.key !== 'email'"
@@ -86,6 +141,7 @@
</a-form-item>
</template>
</div>
<template v-if="showStep(2)">
<div class="form-row full-row">
<div class="form-title poppins-bold">
{{ t('AwardApply.designInformation') }}
@@ -254,8 +310,13 @@
</div>
</a-form-item>
</div>
</template>
</a-form>
<div class="conditions">
<div
class="conditions"
:class="{ mobile: isMobile, tablet: isTablet }"
v-show="showStep(3)"
>
<div class="confitions-title poppins-bold">
{{ t('AwardApply.termsAndConditions') }}
</div>
@@ -273,7 +334,8 @@
</div>
</div>
</div>
<div class="submit-container">
<div class="submit-container" v-if="showStep(3)">
<div class="submit-btn poppins-bold" @click="handleSubmitForm">
{{ t('AwardApply.submitYourDesign') }}
</div>
@@ -282,6 +344,12 @@
</div>
</div>
</div>
<div v-if="(isMobile || isTablet) && step < 3" class="step-container">
<div class="step-btn" @click="handleNextStep">
{{ t('AwardApply.nextStep') }}
</div>
<div class="step-tips">{{ t('AwardApply.stepTips') }}</div>
</div>
</div>
<a-modal
v-model:visible="showModal"
@@ -321,6 +389,7 @@
<script setup lang="ts">
import { ref, onUnmounted, onMounted, computed, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { debounce } from 'lodash-es'
import type { Rule } from 'ant-design-vue/es/form'
@@ -328,22 +397,52 @@ import { message, Upload } from 'ant-design-vue'
import { Https } from '@/utils/request'
import { useRoute } from 'vue-router'
import type { UploadChangeParam } from 'ant-design-vue'
import { useIsMobile, useIsTablet } from '@/utils/isMobile'
import VerifycationCodeInput from './components/VerificationCodeInput.vue'
import UploadStatus from './components/UploadStatus.vue'
import Success from './components/Success.vue'
import Step from './components/Step.vue'
const router = useRouter()
const { isMobile } = useIsMobile()
const { isTablet } = useIsTablet()
const step = ref(0)
const showStep = (val: number) => {
if (!isMobile.value && !isTablet.value) {
return true
} else {
return step.value === val
}
}
// 是否晚于2026年7月15日
const date = ref('2026-07-15')
const isExpired = computed(() => {
const now = new Date()
const targetDate = new Date('2026-07-15')
const targetDate = new Date(date.value)
return now > targetDate
})
const { t } = useI18n()
const route = useRoute()
const isCompleted = ref(false)
let dateClick = 0
const handleTestDate = () => {
dateClick++
if (dateClick % 2 === 0) {
date.value = date.value === '2026-07-15' ? '2026-01-15' : '2026-07-15'
}
}
let completeClick = 0
const handleTestComplete = () => {
completeClick++
if (completeClick % 4 === 0) {
isCompleted.value = !isCompleted.value
}
}
const readOnly = computed(() => {
if (route.query.id && !hasValidEmail.value) {
return true
@@ -633,11 +732,14 @@ const handleSendCode = debounce(async () => {
startCountdown()
emailCode.value = ['', '', '', '', '', ''] // 重置验证码输入
if (!isMobile.value && !isTablet.value) {
showModal.value = true
}
} catch (error) {}
}, 300)
const verifyCode = ref(null)
const mobileVerifyCode = ref('')
const setVerifyCode = (value) => {
verifyCode.value = value
@@ -646,9 +748,14 @@ const setVerifyCode = (value) => {
const handleCloseModal = () => {
showModal.value = false
}
const handleVerifyCode = () => {
const handleVerifyCode = async () => {
// console.log(verifyCode.value)
if (isMobile.value || isTablet.value) {
await formRef.value.validateFields(['email'])
}
if (isMobile.value || isTablet.value) {
verifyCode.value = mobileVerifyCode.value
}
if (verifyCode.value.length !== 6) {
message.error(t('AwardApply.pleaseEnterCompleteCode'))
return
@@ -1071,6 +1178,16 @@ const handleEchoForm = () => {
})
}
const handleNextStep = () => {
if (step.value < 3) {
step.value++
}
}
const handleBackToIntroduction = () => {
router.push('/')
}
onMounted(() => {
if (route.query.id) {
handleEchoForm()
@@ -1104,7 +1221,22 @@ onUnmounted(() => {
background-color: #f5f5f5;
box-sizing: border-box;
}
.success-wrapper {
padding-bottom: 3rem;
.success-container {
&.mobile {
height: auto;
top: initial;
margin: 4rem 3.8rem 0;
padding: 13.5rem 13.2rem;
}
}
&.tablet {
.step-btn {
margin: 6rem 23.3rem 0;
}
}
}
.banner {
height: 54.8rem;
background: url('@/assets/images/award/form_bg.png') no-repeat;
@@ -1125,13 +1257,36 @@ onUnmounted(() => {
color: #c7342c;
font-size: 8rem;
}
.form-header {
height: 16.8rem;
width: calc(100% - 42.8rem);
left: 21.4rem;
bottom: 0;
position: absolute;
}
.steps-container {
height: 6.8rem;
background-color: #fff;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
}
&.mobile {
height: 24.8rem;
background: url('@/assets/images/mobile_version_background/banner_bg.png') no-repeat;
background-size: 100% 100%;
padding: 4.1rem 6rem 0;
.slogan {
font-size: 1.8rem;
margin-bottom: 1.2rem;
}
.title {
font-size: 4rem;
}
}
}
.form-header {
height: 16.8rem;
background-color: #fff;
border-top-left-radius: 0.8rem;
border-top-right-radius: 0.8rem;
@@ -1151,11 +1306,13 @@ onUnmounted(() => {
font-size: 2.4rem;
}
}
}
.form-row {
margin-top: 12rem;
margin-bottom: 6rem;
&.mobile {
margin-top: 0;
}
.form-title {
color: #232323;
@@ -1307,6 +1464,58 @@ onUnmounted(() => {
margin-top: 1rem;
}
}
&.mobile,
&.tablet {
margin-top: 4rem;
.form-row {
margin-top: 0;
}
.form-title {
margin-top: 3rem;
}
}
&.mobile:not(.tablet) {
padding: 0 3.8rem 4rem;
.form-header {
padding: 3rem 3rem 0;
height: auto;
padding-bottom: 2.4rem;
}
.form-content {
padding: 0 3rem 20rem;
.verify-box {
margin-top: 4rem;
}
.ant-form .ant-form-item {
width: 100%;
}
}
}
}
.step-btn {
text-align: center;
margin: 6rem 3rem 0;
background-color: #454545;
height: 5rem;
border-radius: 0.8rem;
color: #fff;
line-height: 5rem;
font-family: 'PoppinsBold';
font-weight: 600;
font-size: 2.4rem;
cursor: pointer;
&.back {
margin: 6rem 6.6rem 0;
}
}
.step-tips {
text-align: center;
margin-top: 2rem;
font-family: 'Instrument';
font-weight: 400;
font-size: 1.8rem;
color: #6d6d6d;
}
:deep(.ant-form-item-label) {
@@ -1438,7 +1647,9 @@ onUnmounted(() => {
}
.conditions {
margin-top: 12rem;
&.mobile {
margin-top: 3rem;
}
&-title {
color: #232323;
font-size: 3rem;
@@ -1486,6 +1697,17 @@ onUnmounted(() => {
}
}
}
&.mobile:not(.tablet) {
.condition-list {
.condition-item {
align-items: flex-start;
height: auto;
min-height: 6rem;
line-height: 2.4rem;
padding: 2rem 1.5rem;
}
}
}
}
.submit-container {

View File

@@ -43,33 +43,27 @@ import banner from '@/assets/images/award/banner.mp4'
import bannerMobile from '@/assets/images/award/banner_mobile.mp4'
import bannerZh from '@/assets/images/award/banner_chinese.mp4'
import bannerZhMobile from '@/assets/images/award/banner_mobile_chinese.mp4'
import { useIsMobile } from '@/utils/isMobile'
import { useIsMobile, useIsTablet } from '@/utils/isMobile'
const { isMobile } = useIsMobile()
const { isTablet } = useIsTablet()
const router = useRouter()
const { locale } = useI18n()
provide('isMobile', isMobile)
const isPad = ref(false)
provide('isPad', isPad)
provide('isPad', isTablet)
const checkIsPad = () => {
const ua = navigator.userAgent.toLowerCase()
const padRegex = /ipad|tablet|playbook|silk/i
const isPadUA = padRegex.test(ua)
const isPadWidth = window.innerWidth > 768 && window.innerWidth <= 1200
isPad.value = isPadUA || isPadWidth
}
onMounted(() => {
checkIsPad()
window.addEventListener('resize', checkIsPad)
router.replace('/')
setTimeout(() => {
console.log('是否平板-------', isTablet.value)
console.log('是否移动端-------', isMobile.value)
}, 1000)
})
onUnmounted(() => {
window.removeEventListener('resize', checkIsPad)
})
const isZh = computed(() => {
return locale.value === 'CHINESE_SIMPLIFIED'
})