feat(competition): 支持决赛环节并添加加时赛管理功能
- 扩展竞赛环节类型,支持决赛环节(RoundType=3) - 新增加时赛创建功能,支持多分组队伍选择 - 添加题目库禁用/启用功能,支持批量操作 - 优化用户端竞赛流程,区分常规赛和加时赛 - 重构操作按钮组件,提高代码复用性
This commit is contained in:
114
apps/admin/src/components/custom/user/ActionButton.vue
Normal file
114
apps/admin/src/components/custom/user/ActionButton.vue
Normal 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>
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
16
apps/admin/src/typings/api/Competition.d.ts
vendored
16
apps/admin/src/typings/api/Competition.d.ts
vendored
@ -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 */
|
||||
|
||||
10
apps/admin/src/typings/api/question.d.ts
vendored
10
apps/admin/src/typings/api/question.d.ts
vendored
@ -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 */
|
||||
|
||||
2
apps/admin/src/typings/components.d.ts
vendored
2
apps/admin/src/typings/components.d.ts
vendored
@ -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']
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user