This commit is contained in:
2026-09-07 10:04:35 +08:00
parent c1589e4792
commit 7c34e41f7e
63 changed files with 3392 additions and 294 deletions
+2 -2
View File
@@ -3,7 +3,7 @@
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "趣味运动会"
"navigationBarTitleText": "赛事管理"
}
},
{
@@ -101,7 +101,7 @@
],
"globalStyle": {
"navigationBarTextStyle": "#000000",
"navigationBarTitleText": "趣味运动会",
"navigationBarTitleText": "赛事管理",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#F8F8F8"
},
+18 -3
View File
@@ -49,10 +49,9 @@
style="flex-shrink:0;width: 120px;height: 90px;border-radius: 5px;" fit="fill"/>
<view
style="margin-left:10px;flex:1;overflow:hidden;display: flex;flex-direction: column;justify-content:space-around;color: #534d4d;">
<view
style="display: flex; align-items: center; column-gap: 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<view class="activity-date-tag">
<t-icon name="time"></t-icon>
{{ moment(item.startTime).format('MM-DD') }}
<text>{{ moment(item.startTime).format('YYYY年MM月DD日') }}</text>
</view>
<view style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<t-icon name="location"></t-icon>
@@ -255,6 +254,22 @@ onMounted(() => {
}
}
.activity-date-tag {
align-self: flex-start;
display: inline-flex;
align-items: center;
column-gap: 4px;
max-width: 100%;
padding: 4px 8px;
border-radius: 12px;
box-sizing: border-box;
color: #f45a5a;
background-color: rgba(244, 90, 90, 0.12);
font-size: 12px;
line-height: 1.2;
white-space: nowrap;
}
.loading-container {
padding: 40rpx 0;
text-align: center;
@@ -4,13 +4,20 @@
<view class="tabs-container">
<view class="tabs-header">
<view class="tabs-title">
<text class="title-text">报名人员</text>
<text class="title-text">报名人员(总{{ unitRegistrationTotal }}人)</text>
<view class="header-actions">
<view class="person-counter">
<text class="counter-text">{{ formData.userList.length }}人</text>
</view>
<view class="header-add-btn" @click="addNewUser">
<t-icon name="add" size="16px"></t-icon>
<view class="header-action-btn header-add-btn" @click="addNewUser">
<AddIcon class="header-action-icon" size="18px" />
<text>添加</text>
</view>
<view v-if="formData.userList.length > 0"
class="header-action-btn header-remove-btn"
@click="removeUser(activeTabIndex)">
<DeleteIcon class="header-action-icon" size="17px" />
<text>删除</text>
</view>
</view>
</view>
@@ -25,9 +32,6 @@
@click="switchTab(index)"
>
<view class="tab-content">
<view class="tab-icon" v-if="activeTabIndex === index">
<t-icon name="user" size="16px"></t-icon>
</view>
<text class="tab-text">{{ user.userName || `人员${index + 1}` }}</text>
</view>
</view>
@@ -45,7 +49,6 @@
label-align="left"
scroll-to-first-error="auto"
labelWidth="100px"
:requiredMark="true"
>
<!-- <view class="form-block">-->
<!-- <view class="form-block-title">-->
@@ -68,104 +71,139 @@
<view class="form-block-title">
<t-icon name="usergroup"></t-icon>
<text>报名人员信息</text>
<text class="form-subtitle">{{ activeTabIndex + 1 }}/{{ formData.userList.length }}</text>
<view class="user-actions">
<view class="remove-user" @click="removeUser(activeTabIndex)" v-if="formData.userList.length > 1">
<t-icon name="delete" size="18px"></t-icon>
</view>
</view>
<text class="form-subtitle">
{{ formData.userList.length > 0 ? activeTabIndex + 1 : 0 }}/{{ formData.userList.length }}
</text>
</view>
<view v-if="formData.userList.length === 0" class="form-empty">
暂无报名人员,请点击“添加”
</view>
<!-- 所有人员的表单,使用v-show控制显示 -->
<view v-for="(user, index) in formData.userList" :key="index" class="form-fields-container"
v-show="activeTabIndex === index">
<t-form-item label="姓名" :name="`userList[${index}].userName`"
:rules="[{required:true,message: '请输入姓名'}]">
<t-form-item v-if="isFieldVisible('userName')" label="姓名" :name="`userList[${index}].userName`"
:rules="fieldRules('userName', '请输入姓名')">
<t-input v-model="user.userName" borderless placeholder="请输入内容"></t-input>
</t-form-item>
<t-form-item label="性别" :name="`userList[${index}].sex`" :rules="[{required:true,message: '请选择性别'}]">
<t-radio-group v-model="user.sex" borderless class="radio-box">
<t-form-item v-if="isFieldVisible('sex')" label="性别" :name="`userList[${index}].sex`"
:rules="fieldRules('sex', '请选择性别')">
<t-radio-group v-model="user.sex" borderless class="radio-box" @change="handleSexChange(user)">
<t-radio :block="false" label="男" value="男"/>
<t-radio :block="false" label="女" value="女"/>
</t-radio-group>
</t-form-item>
<t-form-item label="体重(KG)" :name="`userList[${index}].weight`"
:rules="[{required:true,message: '请输入体重拔河比赛分组需要'}]">
<t-form-item v-if="isFieldVisible('personType')" label="人员类型"
:name="'userList[' + index + '].personType'"
:rules="fieldRules('personType', '请选择人员类型')">
<t-checkbox-group v-model="user.personType" class="radio-box dict-radio-box"
@change="handlePersonTypeChange(user, $event)">
<t-checkbox v-for="option in fieldOptions('personType')" :key="option.value"
:label="option.label" :value="option.value"
:block="false" borderless
:disabled="isPersonTypeOptionDisabled(user, option)"/>
</t-checkbox-group>
</t-form-item>
<t-form-item v-if="isFieldVisible('projectType')" label="项目类型"
:name="'userList[' + index + '].projectType'"
:rules="fieldRules('projectType', '请选择项目类型')">
<t-checkbox-group v-model="user.projectType" class="radio-box dict-radio-box"
@change="handleProjectTypeChange(user, $event)">
<t-checkbox v-for="option in fieldOptions('projectType')" :key="option.value"
:label="option.label" :value="option.value" :block="false" borderless
:disabled="isProjectTypeOptionDisabled(user, option)"/>
</t-checkbox-group>
</t-form-item>
<t-form-item v-if="isFieldVisible('weight')" label="体重(KG)" :name="`userList[${index}].weight`"
:rules="fieldRules('weight', '请输入体重拔河比赛分组需要')">
<t-input placeholder="拔河比赛分组需要" type="number" borderless v-model="user.weight"></t-input>
</t-form-item>
<t-form-item label="单位名称">
<t-form-item v-if="isFieldVisible('unitName')" label="单位名称"
:name="`userList[${index}].unitName`"
:rules="fieldRules('unitName', '请输入单位名称')">
<t-input v-model="user.unitName" borderless placeholder="请输入单位名称" readonly></t-input>
</t-form-item>
<t-form-item label="职务" :name="`userList[${index}].position`"
requiredMark
:rules="[{required:true,message: '请输入职务'}]">
<t-form-item v-if="isFieldVisible('unitName')" label="单位简称">
<t-input :model-value="unitAliasName" borderless readonly></t-input>
</t-form-item>
<t-form-item v-if="isFieldVisible('position')" label="职务" :name="`userList[${index}].position`"
:rules="fieldRules('position', '请输入职务')">
<t-input v-model="user.position" borderless placeholder="请输入职务"></t-input>
</t-form-item>
<!-- <t-form-item label="身份证号" :name="`userList[${index}].idCard`"-->
<!-- :rules="[{required:true,message: '请输入身份证号码'},{idcard:true, message: '请输入正确的身份证号码'}]">-->
<!-- <t-input v-model="user.idCard" borderless placeholder="请输入身份证号码"></t-input>-->
<!-- </t-form-item>-->
<t-form-item label="联系电话" :name="`userList[${index}].phone`"
:rules="[{required:true,message: '请输入联系电话'},{telnumber:true, message: '请输入正确的联系电话'}]">
<t-form-item v-if="isFieldVisible('phone')" label="联系电话" :name="`userList[${index}].phone`"
:rules="fieldRules('phone', '请输入联系电话', [{telnumber:true, message: '请输入正确的联系电话'}])">
<t-input v-model="user.phone" borderless placeholder="请输入联系电话"></t-input>
</t-form-item>
<t-form-item label="是否住宿" :name="`userList[${index}].isStay`"
:rules="[{required:true,message: '请选择是否住宿'}]">
<t-form-item v-if="isFieldVisible('birthday')" label="出生日期"
:name="'userList[' + index + '].birthday'"
:rules="fieldRules('birthday', '请选择出生日期')">
<t-input v-model="user.birthday" type="date" borderless placeholder="请选择出生日期"></t-input>
</t-form-item>
<t-form-item v-if="isFieldVisible('isStay')" label="是否住宿" :name="`userList[${index}].isStay`"
:rules="fieldRules('isStay', '请选择是否住宿')">
<t-radio-group v-model="user.isStay" class="radio-box">
<t-radio :block="false" name="radio" :value="true" label="是"/>
<t-radio :block="false" name="radio" :value="false" label="否"/>
</t-radio-group>
</t-form-item>
<t-form-item label="是否联络员" :name="`userList[${index}].isContacts`"
:rules="[{required:true,message: '请选择是否为联络员'}]">
<t-form-item v-if="isFieldVisible('isContacts')" label="是否联络员" :name="`userList[${index}].isContacts`"
:rules="fieldRules('isContacts', '请选择是否为联络员')">
<t-radio-group v-model="user.isContacts" class="radio-box">
<t-radio :block="false" name="radio" :value="true" label="是"/>
<t-radio :block="false" name="radio" :value="false" label="否"/>
</t-radio-group>
</t-form-item>
<t-form-item label="交通方式" :name="`userList[${index}].traffic`"
:rules="[{required:true,message: '请选择交通方式'}]">
<t-form-item v-if="isFieldVisible('traffic')" label="交通方式" :name="`userList[${index}].traffic`"
:rules="fieldRules('traffic', '请选择交通方式')">
<t-radio-group v-model="user.traffic" class="radio-box">
<t-radio :block="false" name="radio" :value="'自驾'" label="自驾"/>
<t-radio :block="false" name="radio" :value="'公共交通'" label="公共交通"/>
</t-radio-group>
</t-form-item>
<t-form-item v-if="user.traffic === '自驾'" label="车牌号" :name="`userList[${index}].carNo`"
:rules="[{required:true,message: '请输入车牌号'}]">
<t-form-item v-if="isFieldVisible('carNo') && user.traffic === '自驾'" label="车牌号"
:name="`userList[${index}].carNo`" :rules="fieldRules('carNo', '请输入车牌号')">
<t-input v-model="user.carNo" borderless placeholder="请输入车牌号"></t-input>
</t-form-item>
<t-form-item v-if="user.traffic === '公共交通'" label="是否接站" :name="`userList[${index}].isStation`"
:rules="[{required:true,message: '请选择是否接站'}]">
<t-form-item v-if="isFieldVisible('isStation') && user.traffic === '公共交通'" label="是否接站"
:name="`userList[${index}].isStation`" :rules="fieldRules('isStation', '请选择是否接站')">
<t-radio-group v-model="user.isStation" class="radio-box">
<t-radio :block="false" name="radio" :value="true" label="是"/>
<t-radio :block="false" name="radio" :value="false" label="否"/>
</t-radio-group>
</t-form-item>
<t-form-item v-if="user.isStation" label="接站地址" :name="`userList[${index}].stationAddress`"
:rules="[{required:true,message: '请输入接站地址'}]">
<t-form-item v-if="isFieldVisible('stationAddress') && user.traffic === '公共交通' && user.isStation"
label="接站地址" :name="`userList[${index}].stationAddress`"
:rules="fieldRules('stationAddress', '请输入接站地址')">
<t-input v-model="user.stationAddress" borderless placeholder="请输入接站地址" maxlength="50"></t-input>
</t-form-item>
<!-- <t-form-item label="展示才艺" :name="`userList[${index}].isShowSkill`" :rules="[{required:true,message: '请选择是否展示才艺'}]">-->
<!-- <t-radio-group v-model="user.isShowSkill" class="radio-box">-->
<!-- <t-radio :block="false" name="radio" :value="true" label="是"/>-->
<!-- <t-radio :block="false" name="radio" :value="false" label="否"/>-->
<!-- </t-radio-group>-->
<!-- </t-form-item>-->
<!-- <template v-if="formData.userList[index]?.isShowSkill">-->
<!-- <t-form-item label="才艺名称" :name="`userList[${index}].skillName`" :rules="[{required:user.isShowSkill===true,message: '请填写才艺名称'}]">-->
<!-- <t-input v-model="user.skillName" borderless placeholder="请输入才艺名称"></t-input>-->
<!-- </t-form-item>-->
<!-- <t-form-item label="所需器材" :name="`userList[${index}].equip`" :rules="[{required:user.isShowSkill===true,message: '请填写才艺所需器材'}]">-->
<!-- <t-input v-model="user.equip" borderless placeholder="请填写才艺所需器材"></t-input>-->
<!-- </t-form-item>-->
<!-- </template>-->
<t-form-item label="参加茶叙会" :name="`userList[${index}].isParty`"
:rules="[{required:true,message: '请选择是否参加茶叙会'}]">
<t-form-item v-if="isFieldVisible('isShowSkill')" label="展示才艺"
:name="`userList[${index}].isShowSkill`"
:rules="fieldRules('isShowSkill', '请选择是否展示才艺')">
<t-radio-group v-model="user.isShowSkill" class="radio-box">
<t-radio :block="false" name="radio" :value="true" label="是"/>
<t-radio :block="false" name="radio" :value="false" label="否"/>
</t-radio-group>
</t-form-item>
<t-form-item v-if="isFieldVisible('skillName') && user.isShowSkill" label="才艺名称"
:name="`userList[${index}].skillName`" :rules="fieldRules('skillName', '请填写才艺名称')">
<t-input v-model="user.skillName" borderless placeholder="请输入才艺名称"></t-input>
</t-form-item>
<t-form-item v-if="isFieldVisible('equip') && user.isShowSkill" label="所需器材"
:name="`userList[${index}].equip`" :rules="fieldRules('equip', '请填写才艺所需器材')">
<t-input v-model="user.equip" borderless placeholder="请填写才艺所需器材"></t-input>
</t-form-item>
<t-form-item v-if="isFieldVisible('isParty')" label="参加茶叙会" :name="`userList[${index}].isParty`"
:rules="fieldRules('isParty', '请选择是否参加茶叙会')">
<t-radio-group v-model="user.isParty" class="radio-box">
<t-radio :block="false" name="radio" :value="true" label="是"/>
<t-radio :block="false" name="radio" :value="false" label="否"/>
@@ -254,10 +292,237 @@ import {useRequest} from '@/utils/request';
import {Dialog, Toast} from "tdesign-mobile-vue";
import {onLoad} from "@dcloudio/uni-app";
import {useUserStore} from "@/store";
import {showConfirmWithHistory} from "@/utils/popupHistory";
import {AddIcon, DeleteIcon} from "tdesign-icons-vue-next";
const request = useRequest()
const userStore = useUserStore()
// 单位简称属于当前登录用户关联的单位信息,不随单个人员报名数据重复提交。
const unitAliasName = computed(() => userStore?.userInfo?.unit?.aliasName || '未设置')
// 后端不可用时仍按历史字段展示,保证已有活动的报名页面不会变成空表单。
const registrationFields = ref([
{fieldKey: 'userName', required: true, options: []},
{fieldKey: 'sex', required: true, options: []},
{fieldKey: 'weight', required: true, options: []},
{fieldKey: 'unitName', required: true, options: []},
{fieldKey: 'position', required: true, options: []},
{fieldKey: 'phone', required: true, options: []},
{fieldKey: 'isStay', required: true, options: []},
{fieldKey: 'isContacts', required: true, options: []},
{fieldKey: 'traffic', required: true, options: []},
{fieldKey: 'carNo', required: true, options: []},
{fieldKey: 'isStation', required: true, options: []},
{fieldKey: 'stationAddress', required: true, options: []},
{fieldKey: 'isParty', required: true, options: []}
])
const registrationFieldMap = computed(() => {
const result = {}
registrationFields.value.forEach((item) => {
result[item.fieldKey] = item
})
return result
})
// 单项目活动按同一单位限制教练、领队各1人;已有数量不包含当前账号即将被本表单覆盖的旧记录。
const personTypeRuleStatus = ref({
enabled: false,
coachValue: '',
leaderValue: '',
coachCount: 0,
leaderCount: 0,
maxCount: 1
})
// 唯一项目的报名规则状态;existingCount不包含当前账号将被草稿覆盖的旧名单。
const projectTypeRuleStatus = ref({
enabled: false,
rules: []
})
const isFieldVisible = (fieldKey) => Boolean(registrationFieldMap.value[fieldKey])
const fieldOptions = (fieldKey) => registrationFieldMap.value[fieldKey]?.options || []
// 红色必填标识与前端必填校验统一读取当前活动的报名字段配置,避免显示字段被全局标记为必填。
const isFieldRequired = (fieldKey) => Boolean(registrationFieldMap.value[fieldKey]?.required)
const fieldRules = (fieldKey, message, extraRules = []) => {
return [{required: isFieldRequired(fieldKey), message}, ...extraRules]
}
// 人员类型字典值可能由管理员维护,业务判断始终按“运动员”名称动态取得实际编码。
const getAthleteTypeValue = () => fieldOptions('personType').find((item) => item.label === '运动员')?.value
// 人员类型允许“运动员+教练”或“运动员+领队”,但教练和领队不能同时选择。
const isPersonTypeOptionDisabled = (user, option) => {
const selectedValues = Array.isArray(user.personType) ? user.personType : []
if (selectedValues.includes(option.value)) {
return false
}
const options = fieldOptions('personType')
const coachValue = options.find((item) => item.label === '教练')?.value
const leaderValue = options.find((item) => item.label === '领队')?.value
return option.label === '教练' && selectedValues.includes(leaderValue)
|| option.label === '领队' && selectedValues.includes(coachValue)
}
// 选择人员类型后立即合并后台已有数量和当前页面全部人员;超限时撤销本次人员的对应角色。
const handlePersonTypeChange = (user, changedValues) => {
const selectedValues = parseMultiValue(changedValues)
user.personType = selectedValues
const athleteValue = getAthleteTypeValue()
if (athleteValue && !selectedValues.includes(athleteValue) && parseMultiValue(user.projectType).length > 0) {
// 取消运动员身份后原项目已不再合法,立即清除,避免无效组合进入提交阶段。
user.projectType = []
Toast({message: '已取消运动员类型,原选择项目已清空', theme: 'warning'})
}
const status = personTypeRuleStatus.value
if (!status.enabled) {
return
}
const maxCount = Number(status.maxCount) || 1
const countDraftType = (typeValue) => formData.value.userList.filter((item) => {
return parseMultiValue(item.personType).includes(typeValue)
}).length
const coachTotal = Number(status.coachCount || 0) + countDraftType(status.coachValue)
if (status.coachValue && selectedValues.includes(status.coachValue) && coachTotal > maxCount) {
user.personType = selectedValues.filter((value) => value !== status.coachValue)
Toast({message: '同一活动、同一单位最多选择1名教练', theme: 'warning'})
return
}
const leaderTotal = Number(status.leaderCount || 0) + countDraftType(status.leaderValue)
if (status.leaderValue && selectedValues.includes(status.leaderValue) && leaderTotal > maxCount) {
user.personType = selectedValues.filter((value) => value !== status.leaderValue)
Toast({message: '同一活动、同一单位最多选择1名领队', theme: 'warning'})
}
}
// 参加任何项目都必须先选择运动员类型;不满足时撤销本次项目选择并即时提醒。
const handleProjectTypeChange = (user, changedValues) => {
const athleteValue = getAthleteTypeValue()
if (!athleteValue) {
user.projectType = []
Toast({message: '人员类型数据字典配置不完整,请联系管理员', theme: 'warning'})
return
}
if (!parseMultiValue(user.personType).includes(athleteValue)) {
user.projectType = []
Toast({message: '请先选择人员类型中的“运动员”,再选择项目', theme: 'warning'})
return
}
const selectedValues = parseMultiValue(changedValues)
user.projectType = selectedValues
const status = projectTypeRuleStatus.value
if (!status.enabled) {
return
}
// 同一子项按“其他账号已占用人数+当前草稿全部人员”统计;超限项从本次选择中撤销。
const exceededRules = (status.rules || []).filter((rule) => {
if (!selectedValues.includes(rule.typeCode)) {
return false
}
const draftCount = formData.value.userList.filter((item) => {
return parseMultiValue(item.projectType).includes(rule.typeCode)
}).length
return Number(rule.existingCount || 0) + draftCount > Number(rule.personCount)
})
if (exceededRules.length === 0) {
return
}
const exceededValues = exceededRules.map((rule) => rule.typeCode)
user.projectType = selectedValues.filter((value) => !exceededValues.includes(value))
const firstRule = exceededRules[0]
if (Number(firstRule.personCount) === 0) {
Toast({message: '“' + firstRule.typeName + '”当前不允许报名', theme: 'warning'})
return
}
Toast({
message: '“' + firstRule.typeName + '”每个单位最多报名' + firstRule.personCount + '人,请按规则重新选择',
theme: 'warning'
})
}
// 同性别的单打和双打互斥;男子/女子项目受人员性别限制,混合双打和其他不参与互斥。
const isProjectTypeOptionDisabled = (user, option) => {
const selectedValues = parseMultiValue(user.projectType)
if (selectedValues.includes(option.value)) {
return false
}
const options = fieldOptions('projectType')
const manSingleValue = options.find((item) => item.label === '男子单打')?.value
const womanSingleValue = options.find((item) => item.label === '女子单打')?.value
const manDoubleValue = options.find((item) => item.label === '男子双打')?.value
const womanDoubleValue = options.find((item) => item.label === '女子双打')?.value
if (['男子单打', '男子双打'].includes(option.label) && user.sex !== '男') {
return true
}
if (['女子单打', '女子双打'].includes(option.label) && user.sex !== '女') {
return true
}
return option.label === '男子单打' && selectedValues.includes(manDoubleValue)
|| option.label === '男子双打' && selectedValues.includes(manSingleValue)
|| option.label === '女子单打' && selectedValues.includes(womanDoubleValue)
|| option.label === '女子双打' && selectedValues.includes(womanSingleValue)
}
// 后端使用逗号分隔保存多选字典代码;页面编辑时统一转换为数组。
const parseMultiValue = (value) => {
if (Array.isArray(value)) {
return value
}
if (!value) {
return []
}
return String(value).split(',').map((item) => item.trim()).filter(Boolean)
}
// 旧字典的Single、Double按人员性别映射到拆分后的项目,避免历史报名重新提交时校验失败。
const normalizeLegacyProjectType = (user) => {
const selectedValues = parseMultiValue(user.projectType)
const options = fieldOptions('projectType')
const legacyMappings = [
{
value: 'Single',
targetLabel: user.sex === '男' ? '男子单打' : user.sex === '女' ? '女子单打' : ''
},
{
value: 'Double',
targetLabel: user.sex === '男' ? '男子双打' : user.sex === '女' ? '女子双打' : ''
}
]
const normalizedValues = [...selectedValues]
legacyMappings.forEach((mapping) => {
if (!normalizedValues.includes(mapping.value) || !mapping.targetLabel) {
return
}
const targetValue = options.find((item) => item.label === mapping.targetLabel)?.value
if (!targetValue) {
return
}
normalizedValues.splice(normalizedValues.indexOf(mapping.value), 1, targetValue)
})
user.projectType = [...new Set(normalizedValues)]
}
const normalizeLegacyProjectTypes = () => {
formData.value.userList.forEach((user) => normalizeLegacyProjectType(user))
}
// 性别变化后清除不再匹配的单打和双打项目,防止界面保留不可提交的非法组合。
const handleSexChange = (user) => {
const options = fieldOptions('projectType')
const manSingleValue = options.find((item) => item.label === '男子单打')?.value
const womanSingleValue = options.find((item) => item.label === '女子单打')?.value
const manDoubleValue = options.find((item) => item.label === '男子双打')?.value
const womanDoubleValue = options.find((item) => item.label === '女子双打')?.value
normalizeLegacyProjectType(user)
user.projectType = parseMultiValue(user.projectType).filter((value) => {
if (user.sex === '男') {
return value !== womanSingleValue && value !== womanDoubleValue
}
if (user.sex === '女') {
return value !== manSingleValue && value !== manDoubleValue
}
return ![manSingleValue, womanSingleValue, manDoubleValue, womanDoubleValue].includes(value)
})
}
// 当前选中的选项卡索引
const activeTabIndex = ref(0)
@@ -273,8 +538,11 @@ function addNewUser() {
formData.value.userList.push({
userName: '',
sex: null,
personType: [],
projectType: [],
unitName: userStore?.userInfo?.unit?.name,
position: null,
birthday: null,
// idCard: null,
phone: null,
isStay: null,
@@ -282,6 +550,10 @@ function addNewUser() {
isShowSkill: false,
skillName: null,
equip: null,
traffic: null,
isStation: false,
stationAddress: null,
carNo: null,
isParty: false
})
// 切换到新添加的用户选项卡
@@ -366,11 +638,19 @@ const guideSteps = [
// 报名信息
const actId = ref(null)
const isSignUp = ref(null)
const agreementChecked = ref(false)
// 报名页默认视为已阅读并同意,连续维护多人或重新提交时不重复要求阅读。
const agreementChecked = ref(true)
const formRef = ref(null)
const formData = ref({
userList: []
})
// 后端返回当前单位已保存总数;编辑原报名时先扣除旧名单,再合并当前页面草稿人数,避免重复累计。
const savedUnitRegistrationCount = ref(0)
const originalSignupUserCount = ref(0)
const unitRegistrationTotal = computed(() => {
const otherSavedCount = Math.max(0, savedUnitRegistrationCount.value - originalSignupUserCount.value)
return otherSavedCount + formData.value.userList.length
})
// 协议弹窗相关
const agreementPopupVisible = ref(false)
const agreementCountdown = ref(5)
@@ -385,32 +665,123 @@ onLoad(async (option) => {
if (option.id) {
actId.value = option.id
formData.value.activityId = option.id
loadRegistrationFields()
loadUnitRegistrationCount()
await listSignUpUser()
await getIsSignUp()
await getAgreement()
// 没报名进行引导
// 未报名时仍默认带出本人信息;按当前业务要求不再自动显示报名人员操作引导。
if (!formData.value.userList || formData.value.userList.length === 0) {
guideCurrent.value = 0
// 默认是本人的信息
formData.value.userList.push({
userName: userStore.userInfo.userName,
sex: userStore.userInfo.sex,
personType: [],
projectType: [],
unitName: userStore?.userInfo?.unit?.name,
position: null,
birthday: userStore.userInfo.birthday,
// idCard: userStore.userInfo.idCard,
phone: userStore.userInfo.phone,
isStay: null
isStay: null,
isContacts: false,
isShowSkill: false,
skillName: null,
equip: null,
traffic: null,
isStation: false,
stationAddress: null,
carNo: null,
isParty: false
})
}
// 页面加载完成后自动显示重要提醒弹窗
setTimeout(() => {
showNoticePopup()
}, 500)
// 按当前业务要求屏蔽页面加载时自动显示“重要提醒”弹窗。
} else {
Toast({message: 'ID不存在', theme: 'error'});
}
})
// 获取当前活动启用的字段、必填规则以及数据字典选项。
const loadRegistrationFields = () => {
request.get('/client/activity/registration-fields/' + actId.value)
.then(({code, data, msg}) => {
if (code === 0 && data) {
registrationFields.value = data
normalizeLegacyProjectTypes()
loadPersonTypeRuleStatus()
loadProjectTypeRuleStatus()
} else {
Toast({message: msg || '加载报名字段失败', theme: 'error'})
}
})
.catch((error) => {
console.error(error)
Toast({message: '加载报名字段失败,已使用默认报名字段', theme: 'error'})
})
}
// 获取同单位其他报名账号已占用的教练、领队人数,供页面选择时即时校验。
const loadPersonTypeRuleStatus = () => {
if (!isFieldVisible('personType')) {
personTypeRuleStatus.value = {...personTypeRuleStatus.value, enabled: false}
return
}
request.get('/client/activity/person-type-rule-status/' + actId.value)
.then(({code, data, msg}) => {
if (code === 0 && data) {
personTypeRuleStatus.value = data
} else {
personTypeRuleStatus.value = {...personTypeRuleStatus.value, enabled: false}
Toast({message: msg || '加载人员类型规则失败', theme: 'error'})
}
})
.catch((error) => {
console.error(error)
personTypeRuleStatus.value = {...personTypeRuleStatus.value, enabled: false}
Toast({message: '加载人员类型规则失败,提交时将再次校验', theme: 'error'})
})
}
// 获取唯一项目各子项人数上限和同单位其他账号已占用人数,供项目选择时即时校验。
const loadProjectTypeRuleStatus = () => {
if (!isFieldVisible('projectType')) {
projectTypeRuleStatus.value = {...projectTypeRuleStatus.value, enabled: false}
return
}
request.get('/client/activity/project-type-rule-status/' + actId.value)
.then(({code, data, msg}) => {
if (code === 0 && data) {
projectTypeRuleStatus.value = data
} else {
projectTypeRuleStatus.value = {enabled: false, rules: []}
Toast({message: msg || '加载项目报名规则失败', theme: 'error'})
}
})
.catch((error) => {
console.error(error)
projectTypeRuleStatus.value = {enabled: false, rules: []}
Toast({message: '加载项目报名规则失败,提交时将再次校验', theme: 'error'})
})
}
// 查询同一活动、同一单位已保存的报名人数;添加和删除后的变化由计算属性实时合并。
const loadUnitRegistrationCount = () => {
request.get('/client/activity/unit-registration-count/' + actId.value)
.then(({code, data, msg}) => {
if (code === 0) {
savedUnitRegistrationCount.value = Number(data) || 0
} else {
savedUnitRegistrationCount.value = 0
Toast({message: msg || '加载本单位报名总人数失败', theme: 'error'})
}
})
.catch((error) => {
console.error(error)
savedUnitRegistrationCount.value = 0
Toast({message: '加载本单位报名总人数失败', theme: 'error'})
})
}
// 查询参赛承诺书
async function getAgreement() {
try {
@@ -428,8 +799,14 @@ async function listSignUpUser() {
try {
const {code, data} = await request.get('/client/activity/list-signup-user/' + actId.value)
if (code === 0 && data) {
originalSignupUserCount.value = data.length
if (data.length > 0) {
formData.value.userList = data
formData.value.userList = data.map((user) => ({
...user,
personType: parseMultiValue(user.personType),
projectType: parseMultiValue(user.projectType)
}))
normalizeLegacyProjectTypes()
}
} else {
Toast({message: '查询报名人员信息失败', theme: 'error'});
@@ -467,11 +844,11 @@ async function validaForm() {
// 承诺书
async function validateAgreement() {
return new Promise((resolve, reject) => {
// Always show the popup regardless of checkbox state
// if (agreementChecked.value) {
// resolve(true)
// return
// }
// 已同意时直接提交;只有用户主动取消勾选后才重新进入承诺书阅读流程。
if (agreementChecked.value) {
resolve(true)
return
}
// 重置倒计时
agreementCountdown.value = 5
@@ -549,66 +926,99 @@ function closeAgreementPopup() {
// 提交表单
async function onSubmit() {
// 页面允许暂时删除全部人员,但提交报名时至少需要保留一名报名人员。
if (formData.value.userList.length === 0) {
Toast('请至少添加一名报名人员')
return
}
const formRes = await validaForm()
if (!formRes) return
const agreementRes = await validateAgreement()
if (!agreementRes) return
// 提交表单
await submitForm()
// 重新提交会覆盖当前活动的原报名信息,提交前要求用户再次核对。
if (isSignUp.value) {
showConfirmWithHistory({
title: '重新提交确认',
content: '请再次确认报名信息是否正确,确认后将更新原报名信息。',
cancelBtn: '返回检查',
confirmBtn: '确认提交',
closeOnOverlayClick: false,
onConfirm: () => {
submitForm()
}
})
return
}
// 首次报名保持原有流程,校验通过后直接提交。
submitForm()
}
// 报名
async function submitForm() {
function submitForm() {
uni.showLoading({
title: '提交中...',
mask: true,
})
console.log(formData.value)
try {
const {code, data, msg} = await request.post('/client/activity/sign-up', formData.value)
if (code === 0) {
Toast.success('报名成功')
setTimeout(() => {
uni.navigateTo({
url: '/pages/activity/registration-form/success?id=' + actId.value
})
}, 500)
} else {
Dialog.alert({
title: '报名失败',
content: msg,
confirmBtn: '确定'
})
}
} catch (e) {
console.log(e)
Toast.error(e.message)
} finally {
uni.hideLoading()
// 仅转换提交副本,保留页面数组结构,避免提交失败后多选控件状态异常。
const submitData = {
...formData.value,
userList: formData.value.userList.map((user) => ({
...user,
personType: parseMultiValue(user.personType).join(','),
projectType: parseMultiValue(user.projectType).join(',')
}))
}
request.post('/client/activity/sign-up', submitData)
.then(({code, msg}) => {
if (code === 0) {
Toast.success('报名成功')
setTimeout(() => {
uni.navigateTo({
url: '/pages/activity/registration-form/success?id=' + actId.value
})
}, 500)
} else {
Dialog.alert({
title: '报名失败',
content: msg,
confirmBtn: '确定'
})
}
})
.catch((error) => {
console.log(error)
Toast.error(error.message)
})
.finally(() => {
uni.hideLoading()
})
}
// 移除人员
async function removeUser(index) {
if (formData.value.userList.length === 1) {
Toast('至少需要一个报名人员')
return
}
Dialog.confirm({
title: '提示',
content: '确定要删除该报名人员吗?',
confirmBtn: '确定',
function removeUser(index) {
const user = formData.value.userList[index]
const userName = user?.userName || '人员' + (index + 1)
showConfirmWithHistory({
title: '删除报名人员',
content: '确定删除“' + userName + '”吗?如需提交报名,请至少保留或重新添加一名报名人员。',
confirmBtn: '确认删除',
cancelBtn: '取消',
closeOnOverlayClick: false,
onConfirm: () => {
formData.value.userList.splice(index, 1)
// 如果删除的是当前选中的选项卡,则选中第一个选项卡
if (index === activeTabIndex.value) {
if (formData.value.userList.length === 0) {
activeTabIndex.value = 0
return
}
// 如果删除的是前面的选项卡,则当前选中的选项卡索引减1
// 删除当前人员时优先显示其后一人;删除末项时显示前一人,保持操作上下文连续。
if (index === activeTabIndex.value) {
activeTabIndex.value = Math.min(index, formData.value.userList.length - 1)
}
// 删除当前选中项之前的人员后,索引前移以继续显示原选中人员。
else if (index < activeTabIndex.value) {
activeTabIndex.value--
}
@@ -672,13 +1082,18 @@ function closeNoticePopup() {
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.person-counter {
width: 64px;
height: 30px;
background-color: #f45a5a;
border-radius: 12px;
padding: 2px 10px;
margin-right: 8px;
border-radius: 15px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.counter-text {
@@ -687,20 +1102,46 @@ function closeNoticePopup() {
font-weight: 500;
}
.header-add-btn {
width: 28px;
height: 28px;
border-radius: 50%;
background: linear-gradient(135deg, #f45a5a, #ff7676);
color: #fff;
.header-action-btn {
width: 64px;
height: 30px;
padding: 0 8px;
border-radius: 15px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(244, 90, 90, 0.25);
gap: 3px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
box-sizing: border-box;
transition: transform 0.2s ease;
}
.header-add-btn:active {
.header-action-btn text {
white-space: nowrap;
flex-shrink: 0;
}
/* 使用独立 SVG 图标组件,避免 H5 环境下通用 t-icon 未渲染图形。 */
.header-action-icon {
display: block;
flex-shrink: 0;
}
.header-add-btn {
background: linear-gradient(135deg, #f45a5a, #ff7676);
color: #fff;
box-shadow: 0 2px 6px rgba(244, 90, 90, 0.25);
}
.header-remove-btn {
color: #f45a5a;
background-color: #fff1f0;
border: 1px solid rgba(244, 90, 90, 0.35);
}
.header-action-btn:active {
transform: scale(0.92);
}
@@ -740,6 +1181,7 @@ function closeNoticePopup() {
}
.tab-content {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
@@ -749,13 +1191,10 @@ function closeNoticePopup() {
max-width: 100%;
}
.tab-icon {
margin-right: 4px;
display: flex;
align-items: center;
}
.tab-text {
display: block;
width: 100%;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -916,6 +1355,13 @@ function closeNoticePopup() {
animation: fadeIn 0.3s ease;
}
.form-empty {
padding: 36px 0;
color: #999;
font-size: 14px;
text-align: center;
}
@keyframes fadeIn {
from {
opacity: 0.6;
@@ -925,23 +1371,16 @@ function closeNoticePopup() {
}
}
.user-actions {
display: flex;
align-items: center;
margin-left: auto;
}
.remove-user {
padding: 5px;
color: #f45a5a;
cursor: pointer;
}
.radio-box {
display: flex;
column-gap: 20px;
}
.dict-radio-box {
flex-wrap: wrap;
row-gap: 8px;
}
.footer {
position: fixed;
bottom: 0;
@@ -24,7 +24,8 @@ const viewData = ref(null);
function getWonderfulMoment() {
request.post('/platform/activity/wonderfulMoment/getWonderfulMomentById/' + momentId.value).then(res=>{
if(res.code === 0){
res.data.content = res?.data?.content.replaceAll('/open/file/upload/download?filepath=','')
// 后台富文本保存的是后端相对预览地址,H5 端需通过 /api 代理访问实际上传文件。
res.data.content = res?.data?.content.replaceAll('/open/file/upload/download?filepath=', '/api/open/file/upload/download?inline=true&filepath=')
viewData.value = res.data
}else{
Toast({message: res.msg, theme: 'error'});
+47 -2
View File
@@ -4,6 +4,9 @@
<image class="header-bg"
src="/static/img/index4.png"
mode="aspectFill"></image>
<view v-if="currentActivityTitle" class="header-activity-title">
{{ displayActivityTitle }}
</view>
</view>
<view class="menu-container">
@@ -55,7 +58,7 @@
<t-swiper-item v-for="(item, index) in actList" :key="index" style="height: 192px">
<view class="activity-list" @click="navigateTo('/pages/activity/detail/index?id=' + item.id)">
<view class="activity-item">
<t-image :src=config.fileDomain+item.cover shape="round"
<t-image :src="getActivityCoverUrl(item.cover)" shape="round"
class="activity-image" fit="fill"/>
<view class="activity-info">
<view class="activity-title">{{ item.title }}</view>
@@ -105,7 +108,7 @@
</template>
<script setup>
import {onMounted, ref} from "vue";
import {computed, onMounted, ref} from "vue";
import {onLoad, onShow} from "@dcloudio/uni-app";
import {Toast} from "tdesign-mobile-vue";
import {useRequest} from "@/utils/request";
@@ -116,6 +119,20 @@ const request = useRequest()
const actList = ref([])
const wonderfulMomentList = ref([])
// 首页头图展示当前最新开启活动的标题,数据口径与登录页保持一致。
const currentActivityTitle = ref('')
// 当前活动标题在“(公司)”后分行展示,其他标题仍保留原文并按容器宽度自然换行。
const displayActivityTitle = computed(() => {
return currentActivityTitle.value.replace('(公司)', '(公司)\n')
})
// 后台仅保存封面相对路径,首页通过文件预览接口读取活动管理中上传的实际图片。
const getActivityCoverUrl = (cover) => {
if (!cover) {
return ''
}
return '/api/open/file/upload/download?inline=true&filepath=' + encodeURIComponent(cover)
}
function navigateTo(url) {
uni.navigateTo({
@@ -133,6 +150,17 @@ function getActivity() {
})
}
// 公开接口返回当前最新开启活动信息;请求失败时不显示标题,也不阻断首页其他内容。
function getLatestActivityTitle() {
request.get('/open/common/latestActivityOrganizer').then(({code, data}) => {
if (code === 0 && data) {
currentActivityTitle.value = data.title || ''
}
}).catch((error) => {
console.error('获取当前活动标题失败', error)
})
}
function getWonderfulMoment() {
request.post('/platform/activity/wonderfulMoment/getWonderfulMoment').then(res=>{
if(res.code === 0){
@@ -154,6 +182,7 @@ function handleWonderfulMoment(row) {
}
onShow(() => {
getLatestActivityTitle()
getActivity()
getWonderfulMoment()
})
@@ -178,6 +207,22 @@ uni-page-body {
object-fit: cover;
}
.header-activity-title {
position: absolute;
top: 61%;
left: 50%;
z-index: 1;
width: 62%;
transform: translate(-50%, -50%);
color: #fff;
font-size: 16px;
font-weight: 600;
line-height: 1.5;
text-align: center;
white-space: pre-line;
text-shadow: 0 1px 3px rgba(122, 24, 24, 0.45);
}
// 菜单区域样式
.menu-container {
display: flex;
+67 -48
View File
@@ -52,7 +52,7 @@
</div>
<view class="title-content">
<text class="main-title">第二届"钟声杯"直属工会趣味运动会</text>
<text class="main-title">{{ activityOrganizer.title || '赛事管理' }}</text>
<view class="sub-title-container">
<text class="sub-title">欢迎使用</text>
</view>
@@ -89,7 +89,8 @@
/>
</view>
<!-- 验证码输入框 -->
<!-- 测试阶段临时关闭登录验证码输入和发送入口;恢复正式登录时需取消本段注释。 -->
<!--
<view class="input-box" v-if="loginForm.loginMethod==='sms'">
<t-icon name="shield-error" size="20px" class="input-icon"/>
<input
@@ -103,9 +104,10 @@
{{ countDownText }}
</view>
</view>
-->
<!-- 密码输入框 -->
<view class="input-box" v-else>
<view class="input-box" v-if="loginForm.loginMethod==='password'">
<t-icon name="user-password" size="20px" class="input-icon"/>
<input
class="input"
@@ -131,8 +133,8 @@
<!-- 底部信息 -->
<view class="footer-info">
<view class="footer-item">主办单位:浙江省教育工会</view>
<view class="footer-item">承办单位:杭州医学院</view>
<view class="footer-item">主办单位:{{ activityOrganizer.hostUnit || '--' }}</view>
<view class="footer-item">承办单位:{{ activityOrganizer.coHostUnit || '--' }}</view>
<view class="footer-item">技术支持:武汉地大三思科技有限公司(智慧工会提供商)</view>
</view>
</view>
@@ -155,6 +157,27 @@ const loginForm = reactive({
loginMethod: 'sms'
})
// 当前最新开启活动的主办、承办单位;没有可用活动时页面显示“--”。
const activityOrganizer = reactive({
title: '',
hostUnit: '',
coHostUnit: ''
})
// 登录页处于未登录状态,因此通过公开接口获取当前最新开启活动的单位信息。
const getLatestActivityOrganizer = () => {
request.get('/open/common/latestActivityOrganizer').then(({code, data}) => {
if (code === 0 && data) {
activityOrganizer.title = data.title || ''
activityOrganizer.hostUnit = data.hostUnit || ''
activityOrganizer.coHostUnit = data.coHostUnit || ''
}
}).catch((error) => {
// 单位信息属于辅助展示,请求失败时保留占位内容,不阻断登录流程。
console.error('获取当前活动单位信息失败', error)
})
}
// 倒计时相关
const countdown = ref(0)
const timer = ref(null)
@@ -237,7 +260,9 @@ const startCountDown = () => {
// 在页面挂载时恢复倒计时状态
onMounted(() => {
restoreCountdownState()
// 测试阶段不启用登录验证码,因此不再恢复验证码倒计时状态。
// restoreCountdownState()
getLatestActivityOrganizer()
})
// 在页面卸载前清除定时器
@@ -248,82 +273,76 @@ onBeforeUnmount(() => {
}
})
// 获取验证码
async function getSmsCode() {
// 获取登录短信验证码:手机号通过路径参数传递,接口返回 code 和 msg 表示发送结果。
/* 测试阶段临时关闭登录验证码发送;恢复正式登录时需同时恢复验证码输入和后端校验。
const getSmsCode = () => {
if (!loginForm.phone) {
Toast('请输入手机号码');
return
}
// 验证手机号码
if (!/^1[3-9]\d{9}$/.test(loginForm.phone)) {
Toast('请输入正确的手机号码');
return
}
if (isCountingDown.value) return
try {
const {code, data, msg} = await request.post('/client/auth/send-sms-code/' + loginForm.phone)
request.post('/client/auth/send-sms-code/' + loginForm.phone).then(({code, msg}) => {
if (code === 0) {
Toast({message: '验证码发送成功', theme: 'success'})
// 开始倒计时
startCountDown()
} else {
Toast({message: msg, theme: 'error'})
}
} catch (e) {
Toast({message: e.message, theme: 'error'})
}
}).catch((error) => {
Toast({message: error.message, theme: 'error'})
})
}
*/
async function handleLogin() {
const handleLogin = () => {
if (!loginForm.phone) {
Toast('请输入手机号码');
return
}
if (!loginForm.smsCode && loginForm.loginMethod === 'sms') {
Toast('请输入验证码');
return
}
// 测试阶段允许已注册手机号直接登录,暂不校验验证码是否填写。
// if (!loginForm.smsCode && loginForm.loginMethod === 'sms') {
// Toast('请输入验证码');
// return
// }
if (!loginForm.password && loginForm.loginMethod === 'password') {
Toast('请输入密码');
return
}
try {
userStore.smsLogin(loginForm).then(() => {
userStore.getUserInfo().then(() => {
Toast({message: '登录成功', theme: 'success'})
const redirect = uni.getStorageSync("REDIRECT_URL")
if(redirect){
if(redirect.startsWith('/pages/index/index') || redirect.startsWith('/pages/activity/rank/index') || redirect.startsWith('/pages/profile/index')){
uni.switchTab({
url: redirect
})
}else{
uni.navigateTo({
url: redirect
})
}
uni.removeStorageSync("REDIRECT_URL")
}else{
userStore.smsLogin(loginForm).then(() => {
userStore.getUserInfo().then(() => {
Toast({message: '登录成功', theme: 'success'})
const redirect = uni.getStorageSync("REDIRECT_URL")
if(redirect){
if(redirect.startsWith('/pages/index/index') || redirect.startsWith('/pages/activity/rank/index') || redirect.startsWith('/pages/profile/index')){
uni.switchTab({
url: '/pages/index/index'
url: redirect
})
}else{
uni.navigateTo({
url: redirect
})
}
}).catch(e => {
console.log(e)
Toast({message: e.message, theme: 'error'})
})
}).catch(e => {
uni.removeStorageSync("REDIRECT_URL")
}else{
uni.switchTab({
url: '/pages/index/index'
})
}
}).catch((e) => {
console.log(e)
Toast({message: e.message, theme: 'error'})
})
} catch (e) {
console.error(e)
}).catch((e) => {
console.log(e)
Toast({message: e.message, theme: 'error'})
}
})
}
function navigateToRegister() {
@@ -384,7 +403,7 @@ body {
}
.main-title {
font-size: 36px;
font-size: 34px;
font-weight: bold;
margin-bottom: 8px;
display: block;
+68 -10
View File
@@ -53,7 +53,7 @@
</div>
<view class="title-content">
<text class="main-title">第二届"钟声杯"直属工会趣味运动会</text>
<text class="main-title">{{ currentActivityTitle || '赛事管理' }}</text>
<view class="sub-title-container">
<text class="sub-title">用户注册</text>
</view>
@@ -95,6 +95,19 @@
<t-icon name="chevron-right" size="20px" class="arrow-icon"/>
</view>
<!-- 单位简称首次登记后作为单位公共信息使用,已有简称由后台回填且注册时不会被覆盖。 -->
<view class="input-box">
<t-icon name="edit" size="20px" class="input-icon"/>
<input
class="input"
type="text"
v-model="formData.unitAliasName"
placeholder="请输入单位简称(如:南邮工会)"
maxlength="100"
:disabled="unitAliasLocked"
/>
</view>
<!-- &lt;!&ndash; 身份证输入框 &ndash;&gt;-->
<!-- <view class="input-box">-->
<!-- <t-icon name="creditcard" size="20px" class="input-icon"/>-->
@@ -116,8 +129,8 @@
</t-radio-group>
</view>
<!-- 出生年月输入框 -->
<view class="input-box">
<!-- 当前注册流程暂不采集出生日期,保留原选择控件便于后续恢复。 -->
<view v-if="false" class="input-box">
<t-icon name="creditcard" size="20px" class="input-icon"/>
<input
class="input"
@@ -155,6 +168,12 @@
</view>
</view>
<!-- 短信可能被系统拦截或受双卡设置影响,提前提示用户检查手机接收状态。 -->
<view class="sms-reminder">
<text class="sms-reminder-title">温馨提醒:</text>
<text>如果您长时间没有收到短信,请检查手机是否当做骚扰短信被拦截,或您是否有两个手机卡号,可能需要切换主副卡</text>
</view>
<!-- 注册按钮 -->
<view class="submit-btn" @click="handleRegister">
{{ registering ? '注册中...' : '立即注册' }}
@@ -242,14 +261,29 @@ import {useUserStore} from "@/store";
const userStore = useUserStore();
// 注册页与登录页使用同一口径展示当前最新开启活动的标题。
const currentActivityTitle = ref('');
const getLatestActivityTitle = () => {
useRequest().get('/open/common/latestActivityOrganizer').then(({code, data}) => {
if (code === 0 && data) {
currentActivityTitle.value = data.title || '';
}
}).catch((error) => {
// 标题属于辅助展示,请求失败时使用“赛事管理”占位,不阻断注册流程。
console.error('获取当前活动标题失败', error);
});
};
// 表单数据
const formData = reactive({
userName: '',
phone: '',
unitId: '',
unitName: '',
unitAliasName: '',
// idCard: '',
birthday: '',
birthday: null,
smsCode: ''
});
@@ -264,6 +298,7 @@ const unitList = ref([]);
const searchKeyword = ref('');
const selectedUnitId = ref('');
const scrollViewId = ref('');
const unitAliasLocked = ref(false);
// 筛选单位列表
const filteredUnits = computed(() => {
@@ -341,6 +376,8 @@ const confirmUnitSelection = () => {
if (selectedUnit) {
formData.unitId = selectedUnit.id;
formData.unitName = selectedUnit.name;
formData.unitAliasName = selectedUnit.aliasName || '';
unitAliasLocked.value = Boolean(selectedUnit.aliasName);
}
showUnitPicker.value = false;
};
@@ -371,7 +408,7 @@ const sendVerificationCode = async () => {
sendingCode.value = true;
try {
const {code, msg} = await useRequest().post('/client/auth//send-reg-code/' + formData.phone);
const {code, msg} = await useRequest().post('/client/auth/send-reg-code/' + formData.phone);
if (code === 0) {
Toast({message: '验证码已发送'});
@@ -416,14 +453,17 @@ const handleRegister = async () => {
// Toast('请输入身份证号');
// return
// }
if (!formData.birthday) {
Toast('请选择出生日期');
return
}
// 当前注册流程暂不要求出生日期,恢复字段显示时再同步启用必填校验。
if (!formData.unitId) {
Toast('请选择单位');
return
}
const unitAliasName = formData.unitAliasName.trim();
if (!unitAliasName) {
Toast('请输入单位简称');
return
}
formData.unitAliasName = unitAliasName;
// if (!formData.idCard.match(/^[1-9]\d{5}(18|19|([23]\d))\d{2}((0[1-9])|(10|11|12))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/)) {
// Toast('请输入正确的身份证号');
// return
@@ -474,6 +514,7 @@ const goToLogin = () => {
// 页面加载时获取单位列表
onMounted(() => {
getLatestActivityTitle();
getUnitList();
});
</script>
@@ -527,7 +568,7 @@ onMounted(() => {
}
.main-title {
font-size: 36px;
font-size: 34px;
font-weight: bold;
margin-bottom: 8px;
display: block;
@@ -620,6 +661,19 @@ onMounted(() => {
}
}
.sms-reminder {
margin: -6px 10px 10px;
color: #888;
font-size: 12px;
line-height: 1.5;
text-align: left;
}
.sms-reminder-title {
color: #f45a5a;
font-weight: 500;
}
.submit-btn {
margin-top: 32px;
height: 50px;
@@ -637,6 +691,10 @@ onMounted(() => {
}
}
.sms-reminder + .submit-btn {
margin-top: 10px;
}
.login-link-box {
margin-top: 16px;
text-align: center;
+9 -1
View File
@@ -41,7 +41,7 @@
</view>
</view>
<view style="display: flex;">
<image :src="item.cover" mode="aspectFill"
<image :src="getActivityCoverUrl(item.cover)" mode="aspectFill"
style="flex-shrink:0;width: 120px;height: 90px;border-radius: 5px;"></image>
<view
style="margin-left:10px;flex:1;overflow:hidden;display: flex;flex-direction: column;justify-content:space-around;color: #534d4d;">
@@ -118,6 +118,14 @@ const loading = ref(false);
const hasMore = ref(true);
const refresherTriggered = ref(false);
// 后台保存活动封面的相对路径,H5端统一通过当前环境的文件预览代理读取。
const getActivityCoverUrl = (cover) => {
if (!cover) {
return '';
}
return '/api/open/file/upload/download?inline=true&filepath=' + encodeURIComponent(cover);
};
// 选择状态筛选
const selectStatusFilter = (status) => {
pageForm.status = status;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 127 KiB

+3 -2
View File
@@ -1,3 +1,4 @@
export const config = {
fileDomain: 'https://3skj.ltd',
}
// 后台图片字段保存相对路径,统一通过当前环境的 API 代理读取,避免绑定固定部署域名。
fileDomain: '/api/open/file/upload/download?inline=true&filepath=',
}
+100
View File
@@ -0,0 +1,100 @@
import {Dialog} from 'tdesign-mobile-vue';
const popupHistoryStack = [];
let popupHistorySequence = 0;
let popstateListenerRegistered = false;
const canUseBrowserHistory = () => typeof window !== 'undefined' && Boolean(window.history);
// 浏览器返回时只关闭最后打开的弹框,避免直接离开当前业务页面。
const handlePopState = () => {
const popupEntry = popupHistoryStack.pop();
if (popupEntry && !popupEntry.programmaticClosing) {
popupEntry.closePopup();
}
};
const ensurePopstateListener = () => {
if (!canUseBrowserHistory() || popstateListenerRegistered) {
return;
}
window.addEventListener('popstate', handlePopState);
popstateListenerRegistered = true;
};
/**
* 为弹框增加一层浏览器历史。
*
* @param {Function} closePopup 用户通过浏览器返回键关闭弹框时执行的方法
* @return {number|null} 弹框历史记录ID;非浏览器环境返回null
*/
const pushPopupHistory = (closePopup) => {
if (!canUseBrowserHistory()) {
return null;
}
ensurePopstateListener();
popupHistorySequence += 1;
const id = popupHistorySequence;
popupHistoryStack.push({id, closePopup, programmaticClosing: false});
window.history.pushState({popupHistoryId: id}, '', window.location.href);
return id;
};
/**
* 用户点击弹框按钮时同步回退对应的浏览器历史。
*
* @param {number|null} id pushPopupHistory返回的弹框历史记录ID
* @return {void}
*/
const closePopupHistory = (id) => {
if (!canUseBrowserHistory() || id === null) {
return;
}
const popupEntry = popupHistoryStack[popupHistoryStack.length - 1];
if (!popupEntry || popupEntry.id !== id || popupEntry.programmaticClosing) {
return;
}
popupEntry.programmaticClosing = true;
window.history.back();
};
/**
* 显示带H5返回键支持的确认框。
*
* @param {Object} options TDesign确认框参数;onConfirm、onCancel、onClose分别处理确认、取消和关闭
* @return {Object|undefined} TDesign Dialog实例;服务端渲染环境可能返回undefined
*/
export const showConfirmWithHistory = (options = {}) => {
let popupHistoryId = null;
let dialogInstance = null;
const closeHistory = () => {
closePopupHistory(popupHistoryId);
};
dialogInstance = Dialog.confirm({
...options,
onConfirm: (context) => {
closeHistory();
if (options.onConfirm) {
options.onConfirm(context);
}
},
onCancel: (context) => {
closeHistory();
if (options.onCancel) {
options.onCancel(context);
}
},
onClose: (context) => {
closeHistory();
if (options.onClose) {
options.onClose(context);
}
}
});
popupHistoryId = pushPopupHistory(() => {
if (dialogInstance) {
dialogInstance.hide();
}
});
return dialogInstance;
};
+2 -2
View File
@@ -34,8 +34,8 @@ export default defineConfig({
},*/
proxy: {
'/api': {
target: 'http://119.91.224.39:9090/',
// target: 'http://192.168.118.1:9090/',
// target: 'http://119.91.224.39:9090/',
target: 'http://127.0.0.1:9090/',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}