feat(competition): 支持决赛环节并添加加时赛管理功能

- 扩展竞赛环节类型,支持决赛环节(RoundType=3)
- 新增加时赛创建功能,支持多分组队伍选择
- 添加题目库禁用/启用功能,支持批量操作
- 优化用户端竞赛流程,区分常规赛和加时赛
- 重构操作按钮组件,提高代码复用性
This commit is contained in:
2026-03-09 17:34:40 +08:00
parent 8469e16638
commit 11fb5481d3
22 changed files with 468 additions and 142 deletions

View File

@ -0,0 +1,114 @@
<script setup lang="ts">
// import { defineProps, defineEmits } from 'vue';
const props = defineProps({
text: {
type: String,
default: '下一步',
},
disabled: {
type: Boolean,
default: false,
},
theme: {
type: String as () => 'primary' | 'light',
default: 'primary',
},
type: {
type: String as () => 'button' | 'text',
default: 'button',
},
})
const emits = defineEmits(['click'])
function handleClick() {
if (!props.disabled) {
emits('click')
}
}
</script>
<template>
<button
class="action-btn"
:class="[theme, { 'is-disabled': disabled, 'text-btn': type === 'text' }]"
@click="handleClick"
>
<span v-if="type === 'text'" class="btn-text">{{ text }}</span>
<slot v-else />
</button>
</template>
<style lang="scss" scoped>
.action-btn {
width: 50px;
height: 50px;
border-radius: 50%;
border: none;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.1s ease;
background: #eb5e55;
box-shadow: 0 8px 0 #fcc64f;
.arrow-icon {
font-size: 32px;
}
&:hover {
transform: translateY(-2px);
filter: brightness(1.05);
}
&:active {
transform: translateY(4px);
box-shadow: 0 2px 0 #fcc64f;
}
&.text-btn {
width: auto;
min-width: 180px;
padding: 0 30px;
border-radius: 40px;
.btn-text {
font-size: 24px;
font-weight: bold;
letter-spacing: 2px;
}
}
&.is-disabled {
cursor: not-allowed;
&:hover {
transform: none;
}
&:active {
transform: none;
}
}
}
.light {
background: white;
color: #eb5e55;
border: 2px solid #eb5e55;
box-shadow: 0 8px 0 #fcc64f;
&:hover {
background-color: #fff;
transform: translateY(-2px);
}
&:active {
transform: translateY(4px);
box-shadow: 0 2px 0 #fcc64f;
}
}
</style>

View File

@ -6,6 +6,8 @@ import titleBg2 from '@/assets/imgs/user/title-bg2.svg'
import _defaultTitleBg from '@/assets/imgs/user/title-bg.svg'
import { useRouterPush } from '@/hooks/common/router'
import ActionButton from './ActionButton.vue'
interface Props {
/** 是否显示返回按钮 */
showBack?: boolean
@ -29,6 +31,10 @@ interface Props {
nextDisabled?: boolean
/** 标题区域背景图片URL不传则使用默认 title-bg.svg */
titleBgType?: number
/** 是否显示第二个下一步按钮 */
showNextBtn2?: boolean
/** 第二个按钮的文本 */
nextBtnText2?: string
}
const props = withDefaults(defineProps<Props>(), {
@ -43,11 +49,14 @@ const props = withDefaults(defineProps<Props>(), {
btnTheme: 'primary',
nextDisabled: false,
titleBgType: 1,
showNextBtn2: false,
nextBtnText2: undefined,
})
const emit = defineEmits<{
(e: 'back'): void
(e: 'next'): void
(e: 'next2'): void
}>()
const titleBgUrlMapping: Record<number, string> = {
@ -89,6 +98,10 @@ function handleNext() {
emit('next')
}
function handleNext2() {
emit('next2')
}
/** 点击返回首页 */
function handleBackHome() {
routerPushByKey('user_home')
@ -139,21 +152,30 @@ onMounted(() => {
class="page-footer w-full flex items-center justify-between px-20"
:class="[actionPosition, btnTheme]"
>
<button
class="action-btn back-btn" :class="{ 'text-btn': backBtnText }"
:style="{ visibility: showBack ? 'visible' : 'hidden' }" @click="handleBack"
>
<span v-if="backBtnText" class="btn-text">{{ backBtnText }}</span>
<SvgIcon v-else icon="mdi:arrow-left" class="arrow-icon" />
</button>
<div class="left">
<ActionButton
class="back-btn" :style="{ visibility: showBack ? 'visible' : 'hidden' }"
:type="backBtnText ? 'text' : 'button'" :text="backBtnText || ''" :theme="btnTheme" @click="handleBack"
>
<SvgIcon v-if="!backBtnText" icon="mdi:arrow-left" class="arrow-icon" />
</ActionButton>
</div>
<button
v-if="showNext" class="action-btn next-btn text-btn" :class="{ 'is-disabled': nextDisabled }"
@click="handleNext"
>
<span class="btn-text">{{ nextBtnText || '下一步' }}</span>
<!-- <SvgIcon v-else icon="mdi:arrow-right" class="arrow-icon" /> -->
</button>
<div class="right">
<ActionButton
v-if="showNext" :type="nextBtnText ? 'text' : 'button'" :text="nextBtnText || '下一步'"
:theme="btnTheme" :disabled="nextDisabled" @click="handleNext"
>
<SvgIcon v-if="!nextBtnText" icon="mdi:arrow-right" class="arrow-icon" />
</ActionButton>
<ActionButton
v-if="showNextBtn2" :type="nextBtnText2 ? 'text' : 'button'" :text="nextBtnText2 || '下一步'"
:theme="btnTheme" :disabled="nextDisabled" @click="handleNext2"
>
<SvgIcon v-if="!nextBtnText2" icon="mdi:arrow-right" class="arrow-icon" />
</ActionButton>
</div>
</footer>
</div>
</div>
@ -280,102 +302,21 @@ onMounted(() => {
.page-footer {
position: absolute;
bottom: 40px;
right: 0; // 默认在右下角
width: 100%;
display: flex;
justify-content: space-between;
padding: 0 20px;
z-index: 20;
.right {
display: flex;
gap: 10px;
}
&.top {
bottom: auto;
top: 40px;
}
.action-btn {
width: 50px;
height: 50px;
border-radius: 50%;
border: none;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.1s ease;
background: #eb5e55;
box-shadow: 0 8px 0 #fcc64f;
.arrow-icon {
font-size: 32px;
}
&:hover {
transform: translateY(-2px);
filter: brightness(1.05);
}
&:active {
transform: translateY(4px);
box-shadow: 0 2px 0 #fcc64f;
}
// 文字按钮通用样式
&.text-btn {
width: auto;
min-width: 180px;
padding: 0 30px;
border-radius: 40px;
.btn-text {
font-size: 24px;
font-weight: bold;
letter-spacing: 2px;
}
}
&.next-btn {
// 保留原有 next-btn 特定逻辑(如果有)
}
&.back-btn {
// 保持统一风格
}
// 禁用状态
&.is-disabled {
cursor: not-allowed;
// opacity: 0.8; // 移除透明度变化
// filter: grayscale(0.5); // 移除置灰效果
// box-shadow: none; // 保持阴影,或者根据需要调整
&:hover {
transform: none; // 禁止 hover 位移
// filter: grayscale(0.5);
}
&:active {
transform: none;
// box-shadow: none; // 保持阴影
}
}
}
// Light Theme Style (White bg, Red text/border, Yellow shadow)
&.light {
.action-btn {
background: white;
color: #eb5e55;
border: 2px solid #eb5e55;
box-shadow: 0 8px 0 #fcc64f; // 还原黄色立体阴影
&:hover {
background-color: #fff; // 保持白色
transform: translateY(-2px);
}
&:active {
transform: translateY(4px);
box-shadow: 0 2px 0 #fcc64f;
}
}
}
}
// Transitions

View File

@ -25,7 +25,7 @@ export function useRouterPush(inSetup = true) {
* @param key 路由名称
* @param options 路由参数
*/
async function routerPushByKey(key: RouteKey, options?: App.Global.RouterPushOptions) {
async function routerPushByKey(key: RouteKey, options?: App.Global.RouterPushOptions, newWindow?: boolean) {
const { query, params } = options || {}
const routeLocation: RouteLocationRaw = {
@ -40,7 +40,13 @@ export function useRouterPush(inSetup = true) {
routeLocation.params = params
}
return routerPush(routeLocation)
if (newWindow) {
const routeData = router.resolve(routeLocation)
window.open(routeData.href, '_blank')
}
else {
return routerPush(routeLocation)
}
}
/**

View File

@ -58,9 +58,9 @@ export function fetchCreateQuestion(data: Api.Competition.CreateQuestionRequest[
}
/** 根据活动ID获取活动题目列表 */
export function fetchGetQuestionList(id: number) {
export function fetchGetQuestionList(id: number, roundType = 0) {
return request<App.Service.Response<Api.Competition.QuestionListRecord[]>>({
url: `/Base/ActivityMain/GetActivity_QuestionByActivityID?ID=${id}`,
url: `/Base/ActivityMain/GetActivity_QuestionByActivityID?ID=${id}&RoundType=${roundType}`,
method: 'get',
})
}

View File

@ -78,9 +78,9 @@ export function fetchCheckNextStep(TeamGroup_QuestionID: number) {
}
/** 根据活动id查询题目列表 */
export function fetchGetQuestionTableByActivityID(ActivityID: string) {
export function fetchGetQuestionTableByActivityID(ActivityID: string, RoundType: number = 0) {
return request<App.Service.Response<Api.Competition.QuestionListDetailRound[]>>({
url: `/Base/ActivityMain/GetActivity_QuestionTableByActivityID/?ID=${ActivityID}`,
url: `/Base/ActivityMain/GetActivity_QuestionTableByActivityID/?ID=${ActivityID}&RoundType=${RoundType}`,
method: 'get',
})
}
@ -103,3 +103,12 @@ export function fetchGetQuestionDetailByID(QuestionDetailID: number) {
method: 'get',
})
}
/** 加时赛选择组小队 */
export function fetchAddExtraTime(data: Api.Competition.AddExtraTimeParams) {
return request<App.Service.Response<any>>({
url: `/Base/ActivityMain/AddOverTimeTeamGroup`,
method: 'post',
data,
})
}

View File

@ -72,10 +72,20 @@ export function fetchAddQuestionLibrary(params: Api.Question.AddQuestionLibraryP
Type: params.type,
IsGood: params.IsGood,
ImageUrl: params.imageUrl,
IsDisabled: params.IsDisabled,
},
})
}
/** 禁用/启用 一条问题库 */
export function fetchDisableQuestionLibrary(params: Api.Question.DisableQuestionLibraryParams[]) {
return request<Api.Question.CommonRecord>({
url: '/Base/ActivityMain/UpdateQuestionListDetailList',
method: 'post',
data: params,
})
}
/** 更新一条问题库 */
export function fetchUpdateQuestionLibrary(params: Api.Question.AddQuestionLibraryParams, file?: File | null) {
const formData = new FormData()

View File

@ -52,7 +52,7 @@ declare namespace Api {
type QuestionScoreType = 0 | 1 /** 固定分数 | 答题个数 */
/** competition round type */
type CompetitionRoundType = 0 | 1 /** 题包环节 | 加时环节 */
type CompetitionRoundType = 0 | 1 | 3 /** 题包环节 | 加时环节 | 决赛环节 */
/**
* competition publish status
@ -351,6 +351,7 @@ declare namespace Api {
id: number
/** 分组名称(兼容字段) */
name: string
RoundType: number
}
interface CreateQuestionRequest {
@ -380,6 +381,19 @@ declare namespace Api {
roundType: number
}
interface AddExtraTimeParams extends Array<ExtraTimeItem> {}
interface ExtraTimeItem {
GroupName: string
ActivityID: number
TeamInfos: TeamInfo[]
}
interface TeamInfo {
TeamID: number
TeamName: string
}
/** common record */
type CommonRecord<T = any> = {
/** record id */

View File

@ -20,6 +20,14 @@ declare namespace Api {
data: T[]
}
/** disable question library params */
interface DisableQuestionLibraryParams {
/** question library id */
Id: number
/** is disabled 0: no 1: yes */
IsDisabled: boolean
}
/** get question library list all params */
interface GetQuestionLibraryListAllParams {
/** question id */
@ -60,6 +68,8 @@ declare namespace Api {
type: QuestionScoreType
/** is priority 0: no 1: yes */
IsGood: number
/** is disabled 0: no 1: yes */
IsDisabled: number
}
/** common search params of table */

View File

@ -12,6 +12,7 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ActionButton: typeof import('./../components/custom/user/ActionButton.vue')['default']
AppProvider: typeof import('./../components/common/app-provider.vue')['default']
BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default']
ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
@ -186,6 +187,7 @@ declare module 'vue' {
// For TSX support
declare global {
const ActionButton: typeof import('./../components/custom/user/ActionButton.vue')['default']
const AppProvider: typeof import('./../components/common/app-provider.vue')['default']
const BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default']
const ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']

View File

@ -103,7 +103,7 @@ async function fetchTeamListByGroupId(GroupID: number) {
/** 获取活动题目列表 */
async function getQuestionList(id: number) {
const { data, error } = await fetchGetQuestionList(id)
const { data, error } = await fetchGetQuestionList(id, 3)
if (!error) {
questionsList.value = data?.data || []
// eslint-disable-next-line no-console

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { NButton, NCard, NForm, NFormItem, NInput, NInputNumber, NModal, NSelect, NTag, type SelectOption } from 'naive-ui'
import { NButton, NCard, NForm, NFormItem, NInputNumber, NModal, NSelect, NTag, type SelectOption } from 'naive-ui'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
import { PublishStatus } from '@/enum/business'
@ -166,7 +166,7 @@ onMounted(() => {
}
})
function getRoundTypeName(roundType: string) {
function getRoundTypeName(roundType: number) {
const option = roundTypeOptions.value?.find((item: any) => item.value === roundType)
return option?.label || '未知类型'
}
@ -268,14 +268,14 @@ defineExpose({ validate, reset, modelValue })
<div class="flex items-center gap-4">
<div class="h-8 w-1.5 rounded-full bg-blue-500" />
<NTag type="primary" class="!text-blue-500">
{{ getRoundTypeName(round.roundType) }}
{{ getRoundTypeName(Number(round.roundType)) }}
</NTag>
<div class="flex items-center gap-3">
<NInput
<!-- <NInput
v-model:value="round.name" placeholder="请输入环节名称" :bordered="false"
:disabled="!isEdit"
class="border transition-all !w-80 !border-transparent !rounded-lg !bg-transparent !text-xl !font-bold focus-within:shadow-sm focus-within:!border-blue-500 hover:!border-gray-200 focus-within:!bg-white"
/>
/> -->
<div class="flex items-center gap-2">
<div
class="flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-500 font-bold"

View File

@ -25,7 +25,7 @@ import { computed, onUnmounted, ref, watch } from 'vue'
import WangEditor from '@/components/common/wang-editor.vue'
import SvgIcon from '@/components/custom/svg-icon.vue'
import { QuestionCategoryEnum, QuestionType } from '@/enum/business'
import { fetchAddQuestionLibrary, fetchDeleteQuestionLibrary, fetchGetQuestionLibraryListAll, fetchUpdateQuestionLibrary } from '@/service/api/question'
import { fetchAddQuestionLibrary, fetchDeleteQuestionLibrary, fetchDisableQuestionLibrary, fetchGetQuestionLibraryListAll, fetchUpdateQuestionLibrary } from '@/service/api/question'
/**
* 为了user端题目展示的效果以及布局可控指定规则
* 1.普通类型 =》用存文字存 ['汉字加一加','词语听写''汉字听写','成语写一写']
@ -113,6 +113,7 @@ const questionForm = ref({
type: QuestionType.SingleChoice as QuestionType | number, // 题目类型
imageUrl: '',
IsGood: 0,
IsDisabled: 0,
})
const fileList = ref<UploadFileInfo[]>([])
@ -177,6 +178,30 @@ function handleBatchDelete() {
})
}
async function handleBatchDisable(disabled: boolean) {
if (selectedQuestionIds.value.length === 0) {
window?.$message?.warning('请先选择要操作的题目')
return
}
const payload = selectedQuestionIds.value.map(id => ({
Id: id,
IsDisabled: disabled,
}))
const { error } = await fetchDisableQuestionLibrary(payload)
if (!error) {
window?.$message?.success(`批量${disabled ? '禁用' : '启用'}成功`)
selectedQuestionIds.value = []
if (props.currentCategory?.Id) {
fetchData(props.currentCategory.Id)
}
}
else {
window?.$message?.error(`批量${disabled ? '禁用' : '启用'}失败`)
}
}
const rules = {
name: [{ required: true, message: '请输入题目正文内容', trigger: ['blur'] }],
answer: [{ required: true, message: '请输入题目答案', trigger: ['blur'] }],
@ -241,6 +266,7 @@ function handleAddQuestion() {
type: props.currentCategory?.QuestionType || QuestionType.SingleChoice,
imageUrl: '',
IsGood: 0,
IsDisabled: 0,
}
fileList.value = []
showQuestionModal.value = true
@ -265,6 +291,7 @@ function handleEditQuestion(id: number) {
type: question.Type,
imageUrl: question.ImageUrl || '',
IsGood: question.IsPriority || 0,
IsDisabled: question.IsDisabled || 0,
}
fileList.value = []
if (question.ImageUrl) {
@ -317,6 +344,7 @@ async function submitQuestion() {
type: questionForm.value.type as any,
questionId: props.currentCategory.Id,
IsGood: questionForm.value.IsGood,
IsDisabled: questionForm.value.IsDisabled,
}
let fileToUpload: File | null = null
@ -385,11 +413,7 @@ async function submitQuestion() {
<!-- 搜索框 -->
<div v-if="currentCategory" class="mt-2 flex items-center justify-between">
<div class="flex items-center gap-3">
<NCheckbox
:checked="isAllSelected"
:indeterminate="isIndeterminate"
@update:checked="handleSelectAll"
>
<NCheckbox :checked="isAllSelected" :indeterminate="isIndeterminate" @update:checked="handleSelectAll">
全选 ({{ selectedQuestionIds.length }})
</NCheckbox>
<NInput v-model:value="searchText" placeholder="输入题目关键字在当前分类下搜索..." class="!w-80" @keyup.enter="handleSearch">
@ -400,6 +424,24 @@ async function submitQuestion() {
</div>
<div class="flex gap-3">
<NButton
v-if="selectedQuestionIds.length > 0" type="warning" secondary
@click="() => handleBatchDisable(true)"
>
<template #icon>
<SvgIcon icon="carbon:view-off" />
</template>
批量禁用
</NButton>
<NButton
v-if="selectedQuestionIds.length > 0" type="success" secondary
@click="() => handleBatchDisable(false)"
>
<template #icon>
<SvgIcon icon="carbon:view" />
</template>
批量启用
</NButton>
<NButton v-if="selectedQuestionIds.length > 0" type="error" secondary @click="handleBatchDelete">
<template #icon>
<SvgIcon icon="carbon:trash-can" />
@ -440,8 +482,8 @@ async function submitQuestion() {
<NCard
v-for="q in questionList" :key="q.Id" size="small" hoverable class="rounded-xl transition-all" :class="{
'ring-2 ring-primary-500 bg-primary-50': selectedQuestionIds.includes(q.Id),
}"
@click="handleSelect(q.Id, !selectedQuestionIds.includes(q.Id))"
'opacity-50 grayscale': q.IsDisabled === 1,
}" @click="handleSelect(q.Id, !selectedQuestionIds.includes(q.Id))"
>
<template #header>
<div class="flex items-center gap-2" @click.stop>
@ -455,6 +497,9 @@ async function submitQuestion() {
<NTag size="small" :type="q.IsGood === 1 ? 'info' : 'default'" :bordered="false">
{{ q.IsGood === 1 ? '优先' : '随机' }}
</NTag>
<NTag v-if="q.IsDisabled" size="small" type="error" :bordered="false">
已禁用
</NTag>
</div>
</template>
<template #header-extra>
@ -531,7 +576,10 @@ async function submitQuestion() {
<NForm ref="questionFormRef" label-placement="top" :rules="rules" :model="questionForm" size="small">
<NFormItem label="题目正文内容" path="name">
<div v-if="[QuestionCategoryEnum.PoetryComprehension].includes(props.currentCategory?.QuestionType)" class="w-full overflow-hidden border border-gray-200 rounded-lg">
<div
v-if="[QuestionCategoryEnum.PoetryComprehension].includes(props.currentCategory?.QuestionType)"
class="w-full overflow-hidden border border-gray-200 rounded-lg"
>
<WangEditor
v-model="questionForm.name" placeholder="请输入题目内容..." height="300px"
:exclude-keys="['header1', 'fontSize', 'fontFamily', 'lineHeight', 'justifyLeft', 'justifyRight', 'justifyCenter', 'justifyJustify', 'group-image', 'group-video', 'insertTable', 'headerSelect']"
@ -565,6 +613,17 @@ async function submitQuestion() {
</NRadio>
</NRadioGroup>
</NFormItem>
<!-- 是否禁用 -->
<NFormItem label="是否禁用" path="IsDisabled">
<NRadioGroup v-model:value="questionForm.IsDisabled">
<NRadio :value="0">
</NRadio>
<NRadio :value="1">
</NRadio>
</NRadioGroup>
</NFormItem>
<!-- 上传图片 -->
<NFormItem v-if="questionForm.type === QuestionType.Image" label="上传图片">
<NUpload

View File

@ -150,7 +150,7 @@ function edit(item: any) {
<NCard :bordered="false" page-size="small" class="card-wrapper sm:flex-1-hidden">
<template #header>
<div class="flex items-center justify-between">
<div>
<div class="mid-title">
<div class="text-16px font-bold">
{{ rankTitle }}
</div>

View File

@ -186,7 +186,7 @@ async function fetchGroups() {
return
}
try {
const { data, error } = await fetchGetGroupListByActivityID(activityId.value)
const { data, error } = await fetchGetGroupListByActivityID(activityId.value, 3)
if (error) {
window.$message?.error('获取题目失败')
return
@ -213,7 +213,7 @@ async function fetchQuestions() {
if (!activityId.value)
return
try {
const { data, error } = await fetchGetQuestionList(Number(activityId.value))
const { data, error } = await fetchGetQuestionList(Number(activityId.value), 3)
if (error) {
message.error(error.message)
return

View File

@ -55,6 +55,7 @@ function handleNext() {
groupsId: groupsId.value,
teamId: teamId.value,
teamIds: teamIds.value,
RoundType: Number(currentQuestionMainInfo.value?.Activity_Question.RoundType) || 0,
},
})
// 清空当前题目信息
@ -73,7 +74,7 @@ async function initQuestions() {
const { data: outlineData, error } = await fetchGetCurrentQuestion({
ActivityID: Number(activityId.value),
GroupID: Number(groupsId.value),
RoundType: 0,
RoundType: Number(currentQuestionMainInfo.value?.Activity_Question.RoundType) || 0,
})
// eslint-disable-next-line no-console
console.log(outlineData, 'outlineData')

View File

@ -45,6 +45,8 @@ const groupsId = computed(() => Number(route.query?.groupsId)) || activityInfoSt
const teamId = computed(() => activityInfoStore.teamId || Number(route.query?.teamId))
const teamIds = computed(() => route.query?.teamIds as string)
const RoundType = route.query?.RoundType || 0
async function getQuestions() {
if (!activityId.value || !groupsId.value || !teamId.value) {
window?.$message?.error('参数错误')
@ -53,7 +55,7 @@ async function getQuestions() {
loading.value = true
try {
const { data, error } = await fetchGetQuestionTableByActivityID(String(activityId.value))
const { data, error } = await fetchGetQuestionTableByActivityID(String(activityId.value), Number(RoundType))
if (error) {
window?.$message?.error(error.message)
return
@ -115,6 +117,7 @@ async function handleNext() {
groupsId: groupsId.value,
teamId: teamId.value,
teamIds: teamIds.value,
RoundType: Number(RoundType),
},
})
}

View File

@ -17,6 +17,7 @@ const activityId = computed(() => activityInfoStore.activityId || route.query?.a
const groupsId = computed(() => activityInfoStore.groupId || route.query?.groupsId as string)
const teamId = computed(() => activityInfoStore.teamId || route.query?.teamId as string)
const teamIds = computed(() => route.query?.teamIds as string)
const RoundType = computed(() => Number(route.query?.RoundType || 0) as Api.Competition.CompetitionRoundType)
const store = useCompetitionStore()
@ -39,7 +40,7 @@ async function initQuestions() {
const { data: outlineData, error } = await fetchGetCurrentQuestion({
ActivityID: Number(activityId.value),
GroupID: Number(groupsId.value),
RoundType: 0,
RoundType: RoundType.value,
})
// eslint-disable-next-line no-console
console.log(outlineData, 'outlineData')
@ -78,6 +79,7 @@ async function handleCardClick(item: Api.Competition.CurrentQuestionResponse) {
routerPushByKey('user_groups', {
query: {
activityId: activityId.value,
RoundType: RoundType.value,
},
})
return

View File

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
@ -13,6 +13,12 @@ const loading = ref(false)
const activityId = route.query.activityId as string
const isAllEnd = ref(false)
// 是否有加时赛
const hasExtraTime = computed(() => {
const extraTimeGroups = groups.value.filter(item => item.RoundType === 1)
return extraTimeGroups.length > 0 && extraTimeGroups.every(item => item.IsEnd)
})
async function getGroups() {
if (!activityId)
return
@ -32,7 +38,7 @@ async function getGroups() {
icon: 'activity',
}))
}
isAllEnd.value = list.every((item: Api.Competition.ActivityTeamGroup) => item.IsEnd)
isAllEnd.value = list.filter(item => item.RoundType === 0).every((item: Api.Competition.ActivityTeamGroup) => item.IsEnd)
}
finally {
loading.value = false
@ -46,7 +52,7 @@ function handleBack() {
}
function handleNext() {
routerPushByKey('user_rank-list', { query: { activityId } })
routerPushByKey('user_rank-list', { query: { activityId, RoundType: 0 } })
}
function selectGroup(item: Api.Competition.ActivityTeamGroup) {
@ -54,7 +60,14 @@ function selectGroup(item: Api.Competition.ActivityTeamGroup) {
return
// 选择组别逻辑
routerPushByKey('user_teams', { query: { activityId, groupsId: item.Id } })
routerPushByKey('user_teams', { query: { activityId, groupsId: item.Id, RoundType: item.RoundType } })
}
/**
* 查看加时赛结果
*/
function handleNext2() {
routerPushByKey('user_rank-list', { query: { activityId, RoundType: 1 } }, true)
}
onMounted(() => {
@ -67,8 +80,9 @@ onMounted(() => {
<template>
<CompetitionLayout
:show-back="true" :show-next="isAllEnd" next-btn-text="查看队伍结果" :title-bg-type="2"
title="展示组别" @back="handleBack" @next="handleNext"
:show-back="true" :show-next="isAllEnd" next-btn-text="查看常规赛结果" next-btn-text2="查看加时赛结果"
:show-next-btn2="hasExtraTime && isAllEnd" :title-bg-type="2" title="展示组别" @back="handleBack" @next="handleNext"
@next2="handleNext2"
>
<!-- Groups Grid -->
<div class="groups-container">

View File

@ -0,0 +1,121 @@
<script setup lang="ts">
import { NButton, NCard, NInput, NModal, NSelect, NSpace, useMessage } from 'naive-ui'
import { computed, ref } from 'vue'
import { useRouterPush } from '@/hooks/common/router'
import { fetchAddExtraTime } from '@/service/api/game'
const props = defineProps<{
show: boolean
activityId: number
teamOptions: { label: string, value: number }[]
}>()
const emit = defineEmits(['update:show', 'submit'])
const { routerPushByKey } = useRouterPush()
const message = useMessage()
const extraTimeGroups = ref<{
GroupName: string
TeamInfos: number[]
}[]>([{ GroupName: '', TeamInfos: [] }])
const selectedTeams = computed(() => extraTimeGroups.value.flatMap(g => g.TeamInfos))
function getFilteredOptions(currentGroupIndex: number) {
const currentGroupTeams = extraTimeGroups.value[currentGroupIndex].TeamInfos
return props.teamOptions.filter(option =>
!selectedTeams.value.includes(option.value) || currentGroupTeams.includes(option.value),
)
}
function addGroup() {
extraTimeGroups.value.push({ GroupName: '', TeamInfos: [] })
}
function removeGroup(index: number) {
extraTimeGroups.value.splice(index, 1)
}
async function handleSubmit() {
const allSelectedTeams = new Set<number>()
for (const group of extraTimeGroups.value) {
if (!group.GroupName) {
message.warning('请输入所有分组的名称')
return
}
if (group.TeamInfos.length < 2) {
message.warning(`分组 "${group.GroupName}" 至少需要选择两个队伍`)
return
}
for (const teamId of group.TeamInfos) {
if (allSelectedTeams.has(teamId)) {
message.warning('同一个队伍不能出现在多个分组中')
return
}
allSelectedTeams.add(teamId)
}
}
const params: Api.Competition.AddExtraTimeParams = extraTimeGroups.value.map(group => ({
...group,
ActivityID: props.activityId,
TeamInfos: group.TeamInfos.map(teamId => ({
TeamID: teamId,
TeamName: props.teamOptions.find(opt => opt.value === teamId)?.label || '',
})),
}))
const { data, error } = await fetchAddExtraTime(params)
if (error) {
message.error(error.message || '创建加时赛失败')
return
}
message.success('加时赛已创建')
emit('submit', data)
emit('update:show', false)
if (data) {
routerPushByKey('user_cover', { query: { activityId: props.activityId, groupsId: data.data, RoundType: 1 } })
}
}
function handleClose() {
emit('update:show', false)
}
</script>
<template>
<NModal :show="show" @update:show="handleClose">
<NCard style="width: 600px" title="创建加时赛" :bordered="false" size="huge" role="dialog" aria-modal="true">
<NSpace vertical>
<div v-for="(group, index) in extraTimeGroups" :key="index" class="group-item">
<NInput v-model:value="group.GroupName" placeholder="请输入分组名称" />
<NSelect
v-model:value="group.TeamInfos"
multiple
:options="getFilteredOptions(index)"
placeholder="请选择队伍"
/>
<NButton v-if="extraTimeGroups.length > 1" text @click="() => removeGroup(index)">
删除
</NButton>
</div>
<NButton type="dashed" block @click="addGroup">
新增分组
</NButton>
<NButton type="primary" block @click="handleSubmit">
确定
</NButton>
</NSpace>
</NCard>
</NModal>
</template>
<style scoped>
.group-item {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
</style>

View File

@ -4,11 +4,23 @@ import { NDataTable, NImage, NTag, useMessage } from 'naive-ui'
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import title from '@/assets/imgs/user/rank-title.svg'
import ActionButton from '@/components/custom/user/ActionButton.vue'
import { fetchUserRankList } from '@/service/api/rank'
import ExtraTimeModal from './components/ExtraTimeModal.vue'
const route = useRoute()
const message = useMessage()
const MainID = Number(route.query.activityId)
const RoundType = Number(route.query.RoundType) || 0
const hasExtraTimeBtn = RoundType === 0
const showExtraTimeModal = ref(false)
const teamOptions = ref<{ label: string, value: number }[]>([])
function handleAddTime() {
showExtraTimeModal.value = true
}
// 定义数据结构
interface TeamData {
@ -46,7 +58,7 @@ const columns = ref<DataTableColumns<TeamData>>([
const tableData = ref<TeamData[]>([])
async function getUserRankList() {
const { data, error } = await fetchUserRankList(MainID)
const { data, error } = await fetchUserRankList(MainID, RoundType)
if (error) {
message.error(error.message || '获取排行榜失败')
return
@ -101,6 +113,8 @@ async function getUserRankList() {
...questions,
} as TeamData
})
teamOptions.value = data.data.map(item => ({ label: item.Name, value: item.TeamID }))
}
}
@ -129,6 +143,7 @@ function rowClassName(row: TeamData) {
<p class="mt--20px text-center text-sm text-#6b778d">
"勤学如春起之苗,不见其增,日有所长"
</p>
<ActionButton v-if="hasExtraTimeBtn" class="position-absolute bottom-20px right-20px" type="text" text="加时赛" @click="handleAddTime" />
</div>
<div class="leaderboard-content">
<NDataTable
@ -137,6 +152,7 @@ function rowClassName(row: TeamData) {
/>
</div>
</div>
<ExtraTimeModal v-model:show="showExtraTimeModal" :activity-id="MainID" :team-options="teamOptions" />
</CompetitionLayout>
</template>

View File

@ -14,13 +14,14 @@ const activityId = computed(() => activityInfoStore.activityId || route.query?.a
const groupsId = computed(() => activityInfoStore.groupId || route.query?.groupsId as string)
const teamId = computed(() => activityInfoStore.teamId || route.query?.teamId as string)
const teamIds = computed(() => route.query?.teamIds as string)
const RoundType = computed(() => Number(route.query?.RoundType || 0))
const { routerPushByKey, routerBack } = useRouterPush()
const activityName = ref('规则介绍')
const rulesContent = ref('')
const mockContent = computed(() => activityInfo.value?.ActivityContent || '')
const mockContent = computed(() => RoundType.value === 0 ? activityInfo.value?.ActivityContent || '' : activityInfo.value?.ExtraTimeContent || '')
onMounted(() => {
setTimeout(() => {
@ -40,6 +41,7 @@ function handleNext() {
groupsId: groupsId.value,
teamId: teamId.value,
teamIds: teamIds.value,
RoundType: RoundType.value,
},
})
}

View File

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router'
@ -20,6 +20,8 @@ const activityId = (route.query?.activityId) as string || ''
const groupsId = (route.query?.groupsId) as string || ''
const teamIds = ref<number[]>([])
const RoundType = computed(() => Number(route.query?.RoundType || 0))
async function getTeams() {
if (!groupsId)
return
@ -61,7 +63,7 @@ function handleNext() {
function selectTeam(_id: number) {
// 选择队伍逻辑
routerPushByKey('user_cover', { query: { activityId, groupsId, teamId: _id, teamIds: teamIds.value.join(',') } })
routerPushByKey('user_cover', { query: { activityId, groupsId, teamId: _id, teamIds: teamIds.value.join(','), RoundType: RoundType.value } })
}
onMounted(() => {