feat(admin): 新增活动管理功能并优化字典组件
- 新增活动管理相关功能,包括活动创建、编辑、删除和状态管理 - 新增 PublishStatus 枚举定义活动发布状态 - 优化字典组件,支持字符串和数字类型的字典值 - 重构业务数据存储逻辑,简化字典数据获取流程 - 新增活动详情页面,支持分组管理和队伍配置 - 集成富文本编辑器用于活动规则编辑 - 优化图片上传组件,支持小图上传模式 - 修复模板管理中的区域选择和分页问题 - 更新 TypeScript 类型定义,完善 API 接口 - 调整主题配置,新增活动状态相关颜色
This commit is contained in:
@ -10,16 +10,19 @@ const props = withDefaults(defineProps<{
|
||||
/** 图片地址 */
|
||||
modelValue?: string
|
||||
/** 上传路径 */
|
||||
path?: string
|
||||
path?: string // 上传路径,默认 temp/ 目录,同名文件会覆盖
|
||||
/** 提示文字 */
|
||||
hint?: string
|
||||
/** 最大文件大小 (KB) */
|
||||
maxSize?: number
|
||||
/** 接受的文件类型 */
|
||||
accept?: string
|
||||
/** 模式: default-大图上传(带文字), mini-小图上传(仅图标) */
|
||||
variant?: 'default' | 'mini'
|
||||
}>(), {
|
||||
hint: '支持 JPG/PNG 格式',
|
||||
accept: 'image/*',
|
||||
variant: 'default',
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
@ -80,14 +83,19 @@ function handleRemove() {
|
||||
:accept="accept"
|
||||
:show-file-list="false"
|
||||
:custom-request="customRequest"
|
||||
class="block w-full"
|
||||
class="block"
|
||||
:class="{ 'w-full': props.variant === 'default' }"
|
||||
>
|
||||
<div
|
||||
class="relative h-[400px] w-full flex flex-col cursor-pointer items-center justify-center overflow-hidden rounded-3xl bg-[#F5F8FF] transition-all hover:bg-gray-100"
|
||||
:class="{ 'border-2 border-dashed border-gray-300': !modelValue }"
|
||||
class="relative flex flex-col cursor-pointer items-center justify-center overflow-hidden transition-all hover:bg-gray-100"
|
||||
:class="[
|
||||
props.variant === 'default' ? 'h-[400px] w-full rounded-3xl bg-[#F5F8FF]' : 'h-full w-full bg-[#F5F8FF] rounded-lg',
|
||||
!modelValue && props.variant === 'default' ? 'border-2 border-dashed border-gray-300' : '',
|
||||
!modelValue && props.variant === 'mini' ? 'border border-dashed border-gray-300' : '',
|
||||
]"
|
||||
>
|
||||
<div v-if="loading" class="absolute inset-0 z-50 flex items-center justify-center bg-white/50">
|
||||
<NSpin size="large" />
|
||||
<NSpin :size="props.variant === 'mini' ? 'small' : 'large'" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
@ -97,19 +105,40 @@ function handleRemove() {
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<!-- NUpload 的 trigger 会自动处理点击事件 -->
|
||||
<NButton ghost color="#fff" size="small">
|
||||
<NButton v-if="props.variant === 'default'" ghost color="#fff" size="small">
|
||||
更换
|
||||
</NButton>
|
||||
<NButton ghost color="#ff4d4f" size="small" @click.stop="handleRemove">
|
||||
<div v-else class="cursor-pointer text-xs text-white">
|
||||
更换
|
||||
</div>
|
||||
<NButton v-if="props.variant === 'default'" ghost color="#fff" size="small" @click.stop="handleRemove">
|
||||
删除
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<img v-if="modelValue" :src="modelValue" class="h-full w-full object-cover" alt="Poster">
|
||||
<div v-else class="flex flex-col items-center text-gray-400">
|
||||
<div class="i-carbon-add-filled mb-4 text-4xl text-[#3B82F6]" />
|
||||
<span class="text-sm font-bold">点击上传海报</span>
|
||||
<span class="mt-2 text-xs opacity-60">{{ hint }}</span>
|
||||
|
||||
<img
|
||||
v-if="modelValue"
|
||||
:src="modelValue"
|
||||
class="h-full w-full object-cover"
|
||||
alt="uploaded"
|
||||
>
|
||||
|
||||
<div v-else class="flex flex-col items-center justify-center text-gray-400">
|
||||
<template v-if="props.variant === 'default'">
|
||||
<slot name="icon">
|
||||
<icon-ic-baseline-upload class="mb-4 text-6xl" />
|
||||
</slot>
|
||||
<div class="text-lg text-gray-600 font-medium">
|
||||
点击上传
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-gray-400">
|
||||
{{ hint }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<icon-ic-baseline-upload class="text-xl" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@ -119,5 +148,6 @@ function handleRemove() {
|
||||
<style scoped>
|
||||
:deep(.n-upload-trigger) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
6
apps/admin/src/enum/business.ts
Normal file
6
apps/admin/src/enum/business.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export enum PublishStatus {
|
||||
Unpublished = 0,
|
||||
Published = 1,
|
||||
Processing = 2,
|
||||
Finished = 3,
|
||||
}
|
||||
@ -1,18 +1,25 @@
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useBusinessStore } from '@/store/modules/business'
|
||||
|
||||
export function useDict(key: string) {
|
||||
/**
|
||||
* 字典项 hook
|
||||
* @param key 字典项 key
|
||||
* @param valueType 字典项 value 类型,默认 number
|
||||
*/
|
||||
export function useDict(key: string, valueType: 'string' | 'number' = 'number') {
|
||||
const store = useBusinessStore()
|
||||
|
||||
const data = computed(() => store.dictData[key] || [])
|
||||
const loading = computed(() => store.loadingMap[key] || false)
|
||||
|
||||
const options = computed(() => {
|
||||
if (data.value && data.value.length > 0) {
|
||||
return data.value.map((item: any) => ({
|
||||
label: item.uI_Key || item.UI_Key || item.DicKey,
|
||||
value: item.uI_Value || item.UI_Value || item.DicValue,
|
||||
}))
|
||||
return data.value.map((item: any) => {
|
||||
const rawValue = item.uI_Value || item.UI_Value || item.DicValue
|
||||
return {
|
||||
label: item.uI_Key || item.UI_Key || item.DicKey,
|
||||
value: valueType === 'number' ? Number(rawValue) : String(rawValue),
|
||||
}
|
||||
})
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
@ -28,8 +28,12 @@ const SELECTION_KEY = '__selection__'
|
||||
|
||||
const EXPAND_KEY = '__expand__'
|
||||
|
||||
/**
|
||||
* NaiveUI 表格
|
||||
* @param options 表格选项
|
||||
*/
|
||||
export function useNaiveTable<ResponseData, ApiData>(options: UseNaiveTableOptions<ResponseData, ApiData, false>) {
|
||||
const scope = effectScope()
|
||||
const scope = effectScope() // 表格作用域,用于管理表格的响应式数据
|
||||
const appStore = useAppStore()
|
||||
|
||||
const result = useTable<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, false>({
|
||||
@ -77,6 +81,10 @@ type UseNaivePaginatedTableOptions<ResponseData, ApiData> = UseNaiveTableOptions
|
||||
onPaginationParamsChange?: (params: PaginationParams) => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* NaiveUI 分页表格
|
||||
* @param options 表格选项
|
||||
*/
|
||||
export function useNaivePaginatedTable<ResponseData, ApiData>(
|
||||
options: UseNaivePaginatedTableOptions<ResponseData, ApiData>,
|
||||
) {
|
||||
@ -172,21 +180,27 @@ export function useNaivePaginatedTable<ResponseData, ApiData>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NaiveUI 表格操作
|
||||
* @param data 表格数据
|
||||
* @param idKey 表格数据的主键字段
|
||||
* @param getData 获取表格数据的函数
|
||||
*/
|
||||
export function useTableOperate<TableData>(
|
||||
data: Ref<TableData[]>,
|
||||
idKey: keyof TableData,
|
||||
getData: () => Promise<void>,
|
||||
): {
|
||||
drawerVisible: Ref<boolean>
|
||||
openDrawer: () => void
|
||||
closeDrawer: () => void
|
||||
operateType: Ref<NaiveUI.TableOperateType>
|
||||
handleAdd: () => void
|
||||
editingData: Ref<TableData | null>
|
||||
handleEdit: (id: TableData[keyof TableData]) => void
|
||||
checkedRowKeys: Ref<string[]>
|
||||
onBatchDeleted: () => Promise<void>
|
||||
onDeleted: () => Promise<void>
|
||||
drawerVisible: Ref<boolean> /** 抽屉是否可见 */
|
||||
openDrawer: () => void /** 打开抽屉 */
|
||||
closeDrawer: () => void /** 关闭抽屉 */
|
||||
operateType: Ref<NaiveUI.TableOperateType> /** 操作类型 */
|
||||
handleAdd: () => void /** 新增操作 */
|
||||
editingData: Ref<TableData | null> /** 编辑行数据 */
|
||||
handleEdit: (id: TableData[keyof TableData]) => void /** 编辑操作 */
|
||||
checkedRowKeys: Ref<string[]> /** 表格的选中行 keys */
|
||||
onBatchDeleted: () => Promise<void> /** 批量删除操作完成后的钩子 */
|
||||
onDeleted: () => Promise<void> /** 删除操作完成后的钩子 */
|
||||
} {
|
||||
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean()
|
||||
|
||||
@ -253,7 +267,7 @@ export function defaultTransform<ApiData>(
|
||||
const { data, error } = response
|
||||
|
||||
if (!error) {
|
||||
const { list, currentPage: pageNum, pageSize: size, total } = data
|
||||
const { data: list, currentPage: pageNum, pageSize: size, total = 0 } = data
|
||||
|
||||
return {
|
||||
data: list,
|
||||
|
||||
@ -23,6 +23,14 @@ export function fetchCreateActivity(data: Api.Competition.CreateRoomRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取活动的基础信息 */
|
||||
export function fetchGetActivityDetail(id: number) {
|
||||
return request<App.Service.Response<Api.Competition.ActivityDetail>>({
|
||||
url: `/Base/ActivityMain/GetActivityByID/?ID=${id}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 创建活动队伍 */
|
||||
export function fetchCreateTeamList(data: Api.Competition.CreateTeamListRequest[]) {
|
||||
return request<App.Service.Response<Api.Common.CommonResponse>>({
|
||||
@ -32,6 +40,14 @@ export function fetchCreateTeamList(data: Api.Competition.CreateTeamListRequest[
|
||||
})
|
||||
}
|
||||
|
||||
/** 根据活动ID获取活动队伍列表 */
|
||||
export function fetchGetTeamList(id: number) {
|
||||
return request<App.Service.Response>({
|
||||
url: `/Base/ActivityMain/GetActivity_TeamsByMainID/?MainID=${id}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 创建活动题目 */
|
||||
export function fetchCreateQuestion(data: Api.Competition.CreateQuestionRequest[]) {
|
||||
return request<App.Service.Response<Api.Common.CommonResponse>>({
|
||||
@ -40,3 +56,87 @@ export function fetchCreateQuestion(data: Api.Competition.CreateQuestionRequest[
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** 根据活动ID获取活动题目列表 */
|
||||
export function fetchGetQuestionList(id: number) {
|
||||
return request<App.Service.Response<Api.Competition.QuestionListRecord[]>>({
|
||||
url: `/Base/ActivityMain/GetActivity_QuestionByActivityID?ID=${id}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取活动列表 */
|
||||
export function fetchGetActivityList() {
|
||||
return request<App.Service.Response>({
|
||||
url: '/Base/ActivityMain/Activity_MainList',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 新增活动 (包括基础信息、房间、队伍、题目) */
|
||||
export function fetchCreateCompetition(data: any) {
|
||||
return request<App.Service.Response<Api.Common.CommonResponse>>({
|
||||
url: '/Base/ActivityMain/AddActivityAllData',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** 根据活动id查询分组列表 */
|
||||
export function fetchGetGroupList(id: string) {
|
||||
return request<App.Service.Response>({
|
||||
url: `/Base/ActivityMain/GetActivity_TeamGroupByMainID?ActivityID=${id}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 根据组id查询队伍列表 */
|
||||
export function fetchGetTeamListByGroupId(GroupID: number) {
|
||||
return request<App.Service.Response<Api.Competition.TeamListRecord[]>>({
|
||||
url: `/Base/ActivityMain/GetActivity_TeamsByTeamGroupID?GroupID=${GroupID}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 更新活动基础信息 */
|
||||
export function fetchUpdateActivity(data: Api.Competition.ActivityDetail) {
|
||||
return request<App.Service.Response<Api.Common.CommonResponse>>({
|
||||
url: '/Base/ActivityMain/UpdateActivity',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** 更新活动队伍 */
|
||||
export function fetchUpdateTeamList(data: Api.Competition.CreateTeamListRequest[]) {
|
||||
return request<App.Service.Response<Api.Common.CommonResponse>>({
|
||||
url: '/Base/ActivityMain/UpdateActivity_TeamList',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** 更新活动题目 */
|
||||
export function fetchUpdateQuestion(data: Api.Competition.QuestionListRecord[]) {
|
||||
return request<App.Service.Response<Api.Common.CommonResponse>>({
|
||||
url: '/Base/ActivityMain/AddOrUpdateActivity_Question',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除活动 */
|
||||
export function fetchDeleteActivity(id: number) {
|
||||
return request<App.Service.Response<Api.Common.CommonResponse>>({
|
||||
url: `/Base/ActivityMain/DeleteActivity?ID=${id}`,
|
||||
method: 'post',
|
||||
})
|
||||
}
|
||||
|
||||
/** 更新活动状态 */
|
||||
export function fetchUpdatePublishStatus(id: number, status: number) {
|
||||
return request<App.Service.Response<Api.Common.CommonResponse>>({
|
||||
url: `/Base/ActivityMain/UpdateActivityStatus?ActivityID=${id}&status=${status}`,
|
||||
method: 'post',
|
||||
})
|
||||
}
|
||||
|
||||
@ -57,3 +57,14 @@ export function deleteDictionaryItem(DicUIID: number) {
|
||||
data: { DicUIID },
|
||||
})
|
||||
}
|
||||
|
||||
/** 根据字典value查询字典项 */
|
||||
export function getDictionaryItemListByDicID(DicValue: string) {
|
||||
return request<Api.Common.CommonResponse>({
|
||||
url: `/Base/ActivityMain/GetDictionaryListUITypeByDicValue/?DicValue=${DicValue}`,
|
||||
method: 'get',
|
||||
params: {
|
||||
DicValue,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getDictionaryList, getDictionaryListUITypeByDicID } from '@/service/api/dictionary'
|
||||
import { getDictionaryItemListByDicID, getDictionaryList } from '@/service/api/dictionary'
|
||||
|
||||
export const useBusinessStore = defineStore('business-store', () => {
|
||||
const dictList = ref<Api.Dictionary.DictionaryItem[]>([])
|
||||
@ -21,28 +21,17 @@ export const useBusinessStore = defineStore('business-store', () => {
|
||||
|
||||
/** 获取字典数据 */
|
||||
async function getDict(key: string) {
|
||||
if (!isInitialized.value) {
|
||||
await initDict()
|
||||
}
|
||||
|
||||
// 已经加载过,直接返回
|
||||
if (dictData.value[key] && dictData.value[key].length > 0)
|
||||
return dictData.value[key]
|
||||
|
||||
// 查找 ID
|
||||
const dict = dictList.value.find(d => d.DicValue === key)
|
||||
if (!dict) {
|
||||
console.warn(`Dictionary key ${key} not found`)
|
||||
return []
|
||||
}
|
||||
|
||||
if (loadingMap.value[key]) {
|
||||
return []
|
||||
}
|
||||
|
||||
loadingMap.value[key] = true
|
||||
try {
|
||||
const { data: res, error } = await getDictionaryListUITypeByDicID(dict.Id)
|
||||
const { data: res, error } = await getDictionaryItemListByDicID(key)
|
||||
if (!error && res) {
|
||||
const list = res.data || []
|
||||
// 使用新对象赋值,确保触发响应式更新
|
||||
@ -67,4 +56,4 @@ export const useBusinessStore = defineStore('business-store', () => {
|
||||
initDict,
|
||||
getDict,
|
||||
}
|
||||
}, { persist: true })
|
||||
})
|
||||
|
||||
@ -11,9 +11,13 @@ export const themeSettings: App.Theme.ThemeSetting = {
|
||||
success: '#52c41a',
|
||||
warning: '#faad14',
|
||||
error: '#f5222d',
|
||||
unpublished: '#6b7280', // gray-500
|
||||
published: '#2563eb', // blue-600
|
||||
processing: '#16a34a', // green-600
|
||||
finished: '#dc2626', // red-600
|
||||
},
|
||||
isInfoFollowPrimary: true, // 是否开启信息类颜色跟随主题颜色
|
||||
layout: { mode: 'vertical', scrollMode: 'content' }, // 布局模式 mode: horizontal | vertical
|
||||
layout: { mode: 'horizontal', scrollMode: 'content' }, // 布局模式 mode: horizontal | vertical
|
||||
page: { animate: true, animateMode: 'fade-slide' }, // 页面动画模式
|
||||
header: {
|
||||
height: 56, // 头部高度
|
||||
|
||||
@ -1,6 +1,16 @@
|
||||
/** Create color palette vars */
|
||||
function createColorPaletteVars() {
|
||||
const colors: App.Theme.ThemeColorKey[] = ['primary', 'info', 'success', 'warning', 'error']
|
||||
const colors: App.Theme.ThemeColorKey[] = [
|
||||
'primary',
|
||||
'info',
|
||||
'success',
|
||||
'warning',
|
||||
'error',
|
||||
'unpublished',
|
||||
'published',
|
||||
'processing',
|
||||
'finished',
|
||||
]
|
||||
const colorPaletteNumbers: App.Theme.ColorPaletteNumber[] = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
|
||||
|
||||
const colorPaletteVar = {} as App.Theme.ThemePaletteColor
|
||||
|
||||
127
apps/admin/src/typings/api/Competition.d.ts
vendored
127
apps/admin/src/typings/api/Competition.d.ts
vendored
@ -29,10 +29,10 @@ declare namespace Api {
|
||||
* - "1": enabled
|
||||
* - "2": disabled
|
||||
*/
|
||||
type EnableStatus = '1' | '2'
|
||||
type EnableStatus = 1 | 2
|
||||
|
||||
/** question ui type */
|
||||
type QuestionUiType = '1' | '2' | '3' | '4' /** 文本输入 | 单选 | 多选 | 下拉选择 */
|
||||
type QuestionUiType = 1 | 2 | 3 | 4 /** 文本输入 | 单选 | 多选 | 下拉选择 */
|
||||
|
||||
/** question time type */
|
||||
type QuestionTimeType = 5 | 10 | 15 | 20 | 30 | 45 | 60 | 90 /** 5s | 10s | 15s | 20s | 30s | 45s | 60s | 90s */
|
||||
@ -47,10 +47,25 @@ declare namespace Api {
|
||||
| 'TEMPLATE_IDIOM_IMAGE' /** 汉字听写-提示 | 汉字听写-同音 | 汉字加一加 | 词语听写 | 成语-文字要求 | 成语-看图 */
|
||||
|
||||
/** question score type */
|
||||
type QuestionScoreType = '1' | '2' /** 固定分数 | 答题个数 */
|
||||
type QuestionScoreType = 0 | 1 /** 固定分数 | 答题个数 */
|
||||
|
||||
/** competition round type */
|
||||
type CompetitionRoundType = '1' | '2' /** 题包环节 | 加时环节 */
|
||||
type CompetitionRoundType = 0 | 1 /** 题包环节 | 加时环节 */
|
||||
|
||||
/**
|
||||
* competition publish status
|
||||
*
|
||||
* - 0: unpublished
|
||||
* - 1: published
|
||||
* - 2: processing
|
||||
* - 3: finished
|
||||
*/
|
||||
enum PublishStatus {
|
||||
Unpublished = 0,
|
||||
Published = 1,
|
||||
Processing = 2,
|
||||
Finished = 3,
|
||||
}
|
||||
|
||||
/** room */
|
||||
interface Room {
|
||||
@ -60,6 +75,36 @@ declare namespace Api {
|
||||
Name: string
|
||||
}
|
||||
|
||||
/** activity detail */
|
||||
interface ActivityDetail {
|
||||
/** 背景图片 */
|
||||
BackgroundImg: string
|
||||
/** 创建时间 */
|
||||
CreatedTime: string
|
||||
/** 结束时间 */
|
||||
EndTime: string
|
||||
/** 活动 id */
|
||||
Id: number
|
||||
/** 活动名称 */
|
||||
Name: string
|
||||
/** 发布状态 */
|
||||
PublishStatus: number
|
||||
/** 房间 id */
|
||||
RoomID: number
|
||||
/** 开始时间 */
|
||||
StartTime: string
|
||||
/** 队伍分组数量 */
|
||||
TeamGroupNumber: number
|
||||
/** 队伍数量 */
|
||||
Teams: number
|
||||
/** 活动副标题 */
|
||||
ActivityTitle?: string
|
||||
/** 普通赛事规则 */
|
||||
ActivityContent?: string
|
||||
/** 加时赛规则 */
|
||||
ExtraTimeContent?: string
|
||||
}
|
||||
|
||||
/** create room request */
|
||||
interface CreateRoomRequest {
|
||||
/** 活动 id */
|
||||
@ -82,6 +127,28 @@ declare namespace Api {
|
||||
backgroundImg: string
|
||||
}
|
||||
|
||||
/** update room request */
|
||||
interface UpdateRoomRequest {
|
||||
/** 活动 id */
|
||||
id: number
|
||||
/** 活动名称 */
|
||||
name: string
|
||||
/** 开始时间 */
|
||||
startTime: string
|
||||
/** 结束时间 */
|
||||
endTime: string
|
||||
/** 队伍数量 */
|
||||
teams: number
|
||||
/** 队伍分组数量 */
|
||||
teamGroupNumber: number
|
||||
/** 活动副标题 */
|
||||
activityTitle?: string
|
||||
/** 普通赛事规则 */
|
||||
activityContent?: string
|
||||
/** 加时赛规则 */
|
||||
extraTimeContent?: string
|
||||
}
|
||||
|
||||
/** create team list request */
|
||||
interface CreateTeamListRequest {
|
||||
/** 队伍 id */
|
||||
@ -100,6 +167,56 @@ declare namespace Api {
|
||||
schoolName: string
|
||||
/** 队伍分组 id */
|
||||
teamGroupId: number
|
||||
/** 队伍头像 */
|
||||
headImg?: string
|
||||
}
|
||||
|
||||
interface TeamListRecord {
|
||||
/** 队伍 id */
|
||||
Id: number
|
||||
/** 主 id */
|
||||
MainId: number
|
||||
/** 队伍编号 */
|
||||
Number: string
|
||||
/** 队伍名称 */
|
||||
Name: string
|
||||
/** 笔序列号 */
|
||||
PenSerial: string
|
||||
/** 队员名单 */
|
||||
NameList: string
|
||||
/** 学校名称 */
|
||||
SchoolName: string
|
||||
/** 队伍分组 id */
|
||||
TeamGroupId: number
|
||||
/** 队伍头像 */
|
||||
HeadImg?: string
|
||||
}
|
||||
|
||||
interface QuestionListRecord {
|
||||
/** 活动题目名称 */
|
||||
ActitvityQuestionName: string
|
||||
/** 活动 id */
|
||||
ActivityID: number
|
||||
/** 主键 id */
|
||||
ID: number
|
||||
/** 分值 */
|
||||
Point: number
|
||||
/** 题目 id */
|
||||
QuestionID: number
|
||||
/** 题目序号 */
|
||||
QuestionIndex: number
|
||||
/** 题目规则 */
|
||||
QuestionRule: number
|
||||
/** 题目副标题 */
|
||||
QuestionSubTitle: string
|
||||
/** 答题时间(秒) */
|
||||
QuestionTime: number
|
||||
/** 赛题包类型 */
|
||||
RoundType: number
|
||||
/** 题目模板 id */
|
||||
TemplateID: number
|
||||
/** UI 类型 */
|
||||
UIType: QuestionTemplateId
|
||||
}
|
||||
|
||||
interface CreateQuestionRequest {
|
||||
@ -124,7 +241,7 @@ declare namespace Api {
|
||||
/** 题目副标题 */
|
||||
questionSubTitle: string
|
||||
/** 题目模板 id */
|
||||
templateID: QuestionTemplateId
|
||||
templateID: number
|
||||
/** 赛题包类型 */
|
||||
roundType: number
|
||||
}
|
||||
|
||||
4
apps/admin/src/typings/app.d.ts
vendored
4
apps/admin/src/typings/app.d.ts
vendored
@ -140,6 +140,10 @@ declare namespace App {
|
||||
success: string
|
||||
warning: string
|
||||
error: string
|
||||
unpublished: string
|
||||
published: string
|
||||
processing: string
|
||||
finished: string
|
||||
}
|
||||
|
||||
interface ThemeColor extends OtherColor {
|
||||
|
||||
4
apps/admin/src/typings/components.d.ts
vendored
4
apps/admin/src/typings/components.d.ts
vendored
@ -24,6 +24,8 @@ declare module 'vue' {
|
||||
IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-outlined')['default']
|
||||
IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
|
||||
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
||||
'IconIc:baselineDeleteForever': typeof import('~icons/ic/baseline-delete-forever')['default']
|
||||
'IconIc:roundPublish': typeof import('~icons/ic/round-publish')['default']
|
||||
IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['default']
|
||||
IconIcBaselineAddPlus: typeof import('~icons/ic/baseline-add-plus')['default']
|
||||
IconIcBaselineArrowBack: typeof import('~icons/ic/baseline-arrow-back')['default']
|
||||
@ -185,6 +187,8 @@ declare global {
|
||||
const IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-outlined')['default']
|
||||
const IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
|
||||
const IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
||||
const 'IconIc:baselineDeleteForever': typeof import('~icons/ic/baseline-delete-forever')['default']
|
||||
const 'IconIc:roundPublish': typeof import('~icons/ic/round-publish')['default']
|
||||
const IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['default']
|
||||
const IconIcBaselineAddPlus: typeof import('~icons/ic/baseline-add-plus')['default']
|
||||
const IconIcBaselineArrowBack: typeof import('~icons/ic/baseline-arrow-back')['default']
|
||||
|
||||
8
apps/admin/src/typings/wangeditor.d.ts
vendored
Normal file
8
apps/admin/src/typings/wangeditor.d.ts
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
declare module '@wangeditor/editor-for-vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
|
||||
const Editor: DefineComponent<Record<string, any>, Record<string, any>, any>
|
||||
const Toolbar: DefineComponent<Record<string, any>, Record<string, any>, any>
|
||||
|
||||
export { Editor, Toolbar }
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
import { NButton, NModal, NStep, NSteps } from 'naive-ui'
|
||||
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useDict } from '@/hooks/business/useDict'
|
||||
import { fetchCreateActivity, fetchCreateQuestion, fetchCreateTeamList, fetchGetRoomList } from '@/service/api/competition'
|
||||
import { fetchCreateCompetition, fetchGetRoomList } from '@/service/api/competition'
|
||||
/** 房间相关接口 */
|
||||
import BasicInfo from './modules/BasicInfo.vue'
|
||||
|
||||
@ -41,17 +41,20 @@ const competitionData = ref({
|
||||
teamCount: 4, // 每组队伍数量
|
||||
poster: '', // 竞赛海报
|
||||
roomId: null, // 关联场地AP
|
||||
subTitle: '星·辞海遨游', // 活动口号或副标题
|
||||
extraTimeContent: '每道题额外时间10秒', // 加时赛的规则
|
||||
activityContent: '<p>本轮环节共有4道题目,分别是“<strong>诗词理解</strong>”、“<strong>联想对对碰</strong>”、“<strong>逆向接诗句</strong>”、“<strong>情景猜诗句</strong>”</p><p>阅读完题目介绍,主持人点击“<strong>开始作答</strong>”后均在答题本田字格上作答</p><p><br></p><p> “<strong>诗词理解</strong>” 根据题目选择正确答案</p><p> “<strong>联想对对碰</strong>” 根据关键信息写出完整诗句</p><p> “<strong>逆向接诗句</strong>” 根据关键信息写出关联诗句</p><p> “<strong>情景猜诗句</strong>” 根据情景写出关联诗句</p><p><br></p><p>答题倒计时结束后,界面自动跳转到下一题</p>',
|
||||
rounds: [ // 赛题包配置
|
||||
{
|
||||
id: 'round_default',
|
||||
name: '星·辞海遨游',
|
||||
roundType: roundTypeOptions.value?.[0]?.value,
|
||||
roundType: roundTypeOptions.value?.[0]?.value || 0,
|
||||
questions: Array.from({ length: 10 }).map(() => ({
|
||||
value: null,
|
||||
score: 5,
|
||||
time: 30,
|
||||
title: '第1题',
|
||||
scoreType: scoreTypeOptions.value?.[0]?.value,
|
||||
scoreType: scoreTypeOptions.value?.[0]?.value || 0,
|
||||
})),
|
||||
},
|
||||
],
|
||||
@ -93,7 +96,7 @@ async function nextStep() {
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
const { name, startTime, endTime, groupCount, teamCount, poster, rounds, groupManagement, roomId } = competitionData.value
|
||||
const { name, startTime, endTime, groupCount, teamCount, poster, rounds, groupManagement, roomId, subTitle, extraTimeContent, activityContent } = competitionData.value
|
||||
|
||||
if (!startTime || !endTime) {
|
||||
window.$message?.error('请完善比赛时间')
|
||||
@ -107,10 +110,14 @@ async function submitForm() {
|
||||
startTime,
|
||||
endTime,
|
||||
teams: groupCount * teamCount,
|
||||
teamGroupNumber: groupCount,
|
||||
teamGroupNumber: teamCount,
|
||||
createdTime: startTime,
|
||||
roomID: roomId || 0,
|
||||
backgroundImg: poster,
|
||||
publishStatus: 0,
|
||||
ActivityTitle: subTitle,
|
||||
ActivityContent: activityContent,
|
||||
ExtraTimeContent: extraTimeContent,
|
||||
}
|
||||
const teamParams: Api.Competition.CreateTeamListRequest[] = []
|
||||
|
||||
@ -123,11 +130,11 @@ async function submitForm() {
|
||||
id: 0,
|
||||
mainId: 0,
|
||||
number: `${group.id}-${index + 1}`,
|
||||
name,
|
||||
name: team.name,
|
||||
penSerial: `${group.id}-${index + 1}`,
|
||||
nameList: 'to do?',
|
||||
schoolName: name,
|
||||
teamGroupId: group.id && Number(group.id),
|
||||
nameList: `${index + 1}-${team.name}`,
|
||||
schoolName: team.name,
|
||||
teamGroupId: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -141,27 +148,38 @@ async function submitForm() {
|
||||
questionParams.push({
|
||||
id: 0,
|
||||
activityID: 0,
|
||||
questionID: question.questionId,
|
||||
questionIndex: index + 1,
|
||||
actitvityQuestionName: question.title,
|
||||
questionTime: question.time,
|
||||
questionRule: question.scoreType,
|
||||
uiType: question.uiType,
|
||||
point: question.score,
|
||||
templateID: question.templateId,
|
||||
questionID: question.QuestionID,
|
||||
questionIndex: index,
|
||||
actitvityQuestionName: question.ActitvityQuestionName,
|
||||
questionTime: question.QuestionTime,
|
||||
questionRule: question.QuestionRule,
|
||||
uiType: question.UIType,
|
||||
point: question.Point,
|
||||
templateID: question.TemplateID,
|
||||
roundType: round.roundType,
|
||||
questionSubTitle: question.title,
|
||||
questionSubTitle: question.ActitvityQuestionName,
|
||||
})
|
||||
})
|
||||
})
|
||||
// 使用promise.all 提交数据
|
||||
const [competitionRes, teamRes, questionRes] = await Promise.all([
|
||||
fetchCreateActivity(roomParams),
|
||||
fetchCreateTeamList(teamParams),
|
||||
fetchCreateQuestion(questionParams),
|
||||
|
||||
])
|
||||
if (competitionRes && teamRes && questionRes) {
|
||||
// 根据 questionID 降序排序 (例如: 54455 -> 55544)
|
||||
questionParams.sort((a, b) => Number(b.questionID) - Number(a.questionID))
|
||||
|
||||
// 重新计算 questionIndex
|
||||
questionParams.forEach((item, index) => {
|
||||
item.questionIndex = index
|
||||
})
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(questionParams, 'questionParams')
|
||||
|
||||
const { error } = await fetchCreateCompetition({
|
||||
actMain: roomParams,
|
||||
teams: teamParams,
|
||||
questions: questionParams,
|
||||
})
|
||||
|
||||
if (!error) {
|
||||
window.$message?.success('比赛发布成功')
|
||||
emit('success')
|
||||
emit('update:show', false)
|
||||
@ -216,7 +234,7 @@ watch(() => competitionData.value, (val) => {
|
||||
<template>
|
||||
<NModal :show="show" :mask-closable="false" @update:show="handleClose">
|
||||
<div
|
||||
class="relative h-[90vh] max-h-[980px] max-w-[95vw] w-[1280px] flex flex-col overflow-hidden rounded-[2rem] bg-white shadow-2xl transition-all"
|
||||
class="relative h-[90vh] max-h-[1080px] max-w-[95vw] w-[1380px] flex flex-col overflow-hidden rounded-[2rem] bg-white shadow-2xl transition-all"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-10 py-8">
|
||||
|
||||
@ -1,18 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { NDatePicker, NForm, NInput, NInputNumber, NSelect } from 'naive-ui'
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { NAlert, NDatePicker, NForm, NInput, NInputNumber, NSelect } from 'naive-ui'
|
||||
import { onBeforeUnmount, ref, shallowRef, useTemplateRef } from 'vue'
|
||||
import OssImageUpload from '@/components/common/oss-image-upload/index.vue'
|
||||
import { useBasicInfo } from './useBasicInfo'
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: {
|
||||
name: string
|
||||
subTitle?: string
|
||||
startTime: string | null
|
||||
endTime: string | null
|
||||
groupCount: number
|
||||
teamCount: number
|
||||
poster: string
|
||||
roomId: number | null
|
||||
activityContent: string
|
||||
extraTimeContent: string
|
||||
[key: string]: any
|
||||
}
|
||||
/** 房间列表 */
|
||||
@ -25,24 +30,44 @@ const formRef = useTemplateRef('formRef')
|
||||
|
||||
const { formData, updateCount, validate, reset } = useBasicInfo(props, emit)
|
||||
|
||||
// Editor Logic
|
||||
const editorRef = shallowRef()
|
||||
const mode = 'default'
|
||||
const toolbarConfig = {
|
||||
excludeKeys: ['group-video', 'insertVideo', 'uploadVideo'],
|
||||
}
|
||||
const editorConfig = { placeholder: '请输入具体的比赛参与规则及计分标准...' }
|
||||
const activeTab = ref('normal') // normal or extra
|
||||
|
||||
function handleCreated(editor: any) {
|
||||
editorRef.value = editor
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value
|
||||
if (editor == null)
|
||||
return
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
defineExpose({ validate, reset })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex gap-12">
|
||||
<div class="h-full flex gap-12 overflow-hidden">
|
||||
<!-- 左侧表单区域 -->
|
||||
<div class="flex-1">
|
||||
<div class="mb-12">
|
||||
<h2 class="mb-4 text-4xl text-gray-800 font-bold">
|
||||
<div class="no-scrollbar h-full flex-1 overflow-y-auto">
|
||||
<div class="mb-6">
|
||||
<h2 class="mb-2 text-3xl text-gray-800 font-bold">
|
||||
第一步: 基础定义
|
||||
</h2>
|
||||
<p class="text-lg text-gray-400 font-bold">
|
||||
<p class="text-16px text-gray-400 font-bold">
|
||||
请确认比赛名称与参与规模。这将作为系统自动生成分组与终端连接数的依据。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<NForm ref="formRef" label-placement="top" :show-feedback="false">
|
||||
<div class="mb-10">
|
||||
<div class="mb-6">
|
||||
<div class="mb-3 text-sm text-[#8DA5C3] font-bold">
|
||||
比赛项目名称
|
||||
<span class="text-red-500">*</span>
|
||||
@ -53,7 +78,18 @@ defineExpose({ validate, reset })
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-10">
|
||||
<div class="mb-6">
|
||||
<div class="mb-3 text-sm text-[#8DA5C3] font-bold">
|
||||
活动副标题
|
||||
<span class="ml-2 text-gray-400 font-normal">(选填)</span>
|
||||
</div>
|
||||
<NInput
|
||||
v-model:value="formData.subTitle" placeholder="请输入活动口号或副标题"
|
||||
class="h-[3rem] rounded-2xl border-none text-xl leading-[3rem] shadow-[0_2px_10px_rgba(0,0,0,0.02)] !bg-[#FCFCFC]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<div class="mb-3 text-sm text-[#8DA5C3] font-bold">
|
||||
关联场地AP
|
||||
<span class="text-red-500">*</span>
|
||||
@ -66,10 +102,10 @@ defineExpose({ validate, reset })
|
||||
:theme-overrides="{
|
||||
peers: {
|
||||
InternalSelection: {
|
||||
heightLarge: '3.4rem',
|
||||
heightLarge: '3rem',
|
||||
color: '#FCFCFC',
|
||||
borderRadius: '1rem',
|
||||
fontSizeLarge: '1.25rem',
|
||||
fontSizeLarge: '1.2rem',
|
||||
borderHover: 'none',
|
||||
borderFocus: 'none',
|
||||
boxShadowHover: 'none',
|
||||
@ -95,8 +131,8 @@ defineExpose({ validate, reset })
|
||||
Input: {
|
||||
borderRadius: '1rem',
|
||||
color: '#FCFCFC',
|
||||
heightLarge: '3.4rem',
|
||||
fontSizeLarge: '1.25rem',
|
||||
heightLarge: '3rem',
|
||||
fontSizeLarge: '1.2rem',
|
||||
textColor: '#1f2937',
|
||||
borderHover: 'none',
|
||||
borderFocus: 'none',
|
||||
@ -118,7 +154,7 @@ defineExpose({ validate, reset })
|
||||
Input: {
|
||||
borderRadius: '1rem',
|
||||
color: '#FCFCFC',
|
||||
heightLarge: '3.4rem',
|
||||
heightLarge: '3rem',
|
||||
fontSizeLarge: '1.25rem',
|
||||
textColor: '#1f2937',
|
||||
borderHover: 'none',
|
||||
@ -222,22 +258,81 @@ defineExpose({ validate, reset })
|
||||
</NForm>
|
||||
</div>
|
||||
|
||||
<!-- 右侧海报上传区域 -->
|
||||
<div class="w-99 flex flex-col pt-32">
|
||||
<div class="mb-3 w-full text-sm text-[#8DA5C3] font-bold">
|
||||
活动海报
|
||||
<NAlert type="info" class="mt-2 text-xs text-[#616263] font-normal">
|
||||
<span class="text-xs font-normal">
|
||||
建议上传 16:9 或 4:3 比例的图片,以获得最佳的全屏显示效果。
|
||||
</span>
|
||||
</NAlert>
|
||||
<!-- 右侧区域:规则编辑与海报 -->
|
||||
<div class="no-scrollbar h-full flex flex-col flex-1 gap-10 overflow-y-auto pr-2">
|
||||
<!-- 活动规则 -->
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="text-sm text-[#8DA5C3] font-bold">
|
||||
活动规则配置
|
||||
<span class="text-red-500">*</span>
|
||||
</div>
|
||||
<!-- 切换 Tab -->
|
||||
<div class="flex rounded-lg bg-gray-100 p-1">
|
||||
<button
|
||||
class="rounded-md px-4 py-1.5 text-sm font-medium transition-all"
|
||||
:class="activeTab === 'normal' ? 'bg-[#3B82F6] text-white shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||
@click="activeTab = 'normal'"
|
||||
>
|
||||
普通赛事
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md px-4 py-1.5 text-sm font-medium transition-all"
|
||||
:class="activeTab === 'extra' ? 'bg-[#3B82F6] text-white shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||
@click="activeTab = 'extra'"
|
||||
>
|
||||
加时赛
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden border border-gray-100 rounded-3xl bg-white shadow-sm">
|
||||
<Toolbar
|
||||
style="border-bottom: 1px solid #eee"
|
||||
:editor="editorRef"
|
||||
:default-config="toolbarConfig"
|
||||
:mode="mode"
|
||||
/>
|
||||
<div class="relative h-[400px]">
|
||||
<div v-show="activeTab === 'normal'" class="h-full">
|
||||
<Editor
|
||||
v-model="formData.activityContent"
|
||||
style="height: 100%; overflow-y: hidden;"
|
||||
:default-config="{ ...editorConfig, placeholder: '请输入普通赛事的参与规则及计分标准...' }"
|
||||
:mode="mode"
|
||||
@on-created="handleCreated"
|
||||
/>
|
||||
</div>
|
||||
<div v-show="activeTab === 'extra'" class="h-full">
|
||||
<Editor
|
||||
v-model="formData.extraTimeContent"
|
||||
style="height: 100%; overflow-y: hidden;"
|
||||
:default-config="{ ...editorConfig, placeholder: '请输入加时赛的参与规则及计分标准...' }"
|
||||
:mode="mode"
|
||||
@on-created="handleCreated"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 海报上传 -->
|
||||
<div class="w-full">
|
||||
<div class="mb-3 w-full text-sm text-[#8DA5C3] font-bold">
|
||||
活动海报
|
||||
<NAlert type="info" class="mt-2 text-xs text-[#616263] font-normal" :bordered="false">
|
||||
<span class="text-xs font-normal">
|
||||
建议上传 16:9 或 4:3 比例的图片,以获得最佳的全屏显示效果。
|
||||
</span>
|
||||
</NAlert>
|
||||
</div>
|
||||
<OssImageUpload
|
||||
v-model="formData.poster"
|
||||
hint="支持 JPG/PNG 格式"
|
||||
accept="image/*"
|
||||
:max-size="5120"
|
||||
/>
|
||||
</div>
|
||||
<OssImageUpload
|
||||
v-model="formData.poster"
|
||||
hint="支持 JPG/PNG 格式"
|
||||
accept="image/*"
|
||||
:max-size="5120"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -252,4 +347,12 @@ defineExpose({ validate, reset })
|
||||
:deep(.n-upload-trigger) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import { debounce } from '@sa/utils'
|
||||
import { NButton, NGrid, NGridItem, NInput } from 'naive-ui'
|
||||
import { toRaw, watch } from 'vue'
|
||||
import OssImageUpload from '@/components/common/oss-image-upload/index.vue'
|
||||
import { useExcelExport, useExcelImport, useGroupManagement } from './useGroupManagement'
|
||||
|
||||
const props = defineProps<{
|
||||
@ -79,10 +80,20 @@ defineExpose({ validate, reset })
|
||||
<div class="mb-1 text-xs text-gray-400 font-bold">
|
||||
<!-- 显示分组序号,从1到teamCount -->
|
||||
TEAM {{ tIndex + 1 }}
|
||||
<!-- // 显示分组序号,从1到groups.length*teamCount -->
|
||||
<!-- TEAM {{ (index + 1) * (props.config?.teamCount ?? 0) - (props.config?.teamCount ?? 0) + tIndex + 1 }} -->
|
||||
</div>
|
||||
<NInput v-model:value="team.name" placeholder="输入队伍名称" class="rounded-lg !bg-white" />
|
||||
<div class="flex items-center gap-3">
|
||||
<OssImageUpload
|
||||
v-model="team.headImg"
|
||||
variant="mini"
|
||||
class="h-10 w-10 shrink-0"
|
||||
/>
|
||||
<!-- 队伍名称输入框 -->
|
||||
<NInput
|
||||
v-model:value="team.name"
|
||||
placeholder="输入队伍名称"
|
||||
class="flex-1 rounded-lg !bg-white"
|
||||
/>
|
||||
</div>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
</div>
|
||||
|
||||
@ -12,16 +12,16 @@ const props = defineProps<{
|
||||
rounds: Array<{
|
||||
id: string
|
||||
name: string
|
||||
roundType: string
|
||||
roundType: number
|
||||
questions: Array<{
|
||||
id: string
|
||||
questionId: string | null
|
||||
time: number
|
||||
title: string
|
||||
scoreType: Api.Competition.QuestionScoreType
|
||||
score: number
|
||||
uiType?: string
|
||||
templateId?: number
|
||||
QuestionID: number
|
||||
QuestionTime: number
|
||||
ActitvityQuestionName: string
|
||||
QuestionRule: number
|
||||
Point: number
|
||||
UIType?: string
|
||||
TemplateID?: number
|
||||
}>
|
||||
}>
|
||||
}
|
||||
@ -30,7 +30,7 @@ const props = defineProps<{
|
||||
const { options: roundTypeOptions } = useDict('round_type')
|
||||
const { options: scoreTypeOptions } = useDict('score_type')
|
||||
const { options: timeOptions } = useDict('time_out')
|
||||
const { options: uiTypeOptions } = useDict('ui_type')
|
||||
const { options: uiTypeOptions } = useDict('ui_type', 'string')
|
||||
|
||||
const {
|
||||
// getRoundScore,
|
||||
@ -39,6 +39,8 @@ const {
|
||||
addRound,
|
||||
removeRound,
|
||||
addQuestion,
|
||||
handleDragEnd,
|
||||
handleTypeChange,
|
||||
removeQuestion,
|
||||
validate,
|
||||
reset,
|
||||
@ -47,30 +49,25 @@ const {
|
||||
const questionOptions = ref<SelectOption[]>([])
|
||||
|
||||
const templateIdOptions = ref<SelectOption[]>([])
|
||||
const isDataLoaded = ref(false)
|
||||
|
||||
// 模态框控制
|
||||
const showAddModal = ref(false)
|
||||
const newRoundForm = ref({
|
||||
name: '',
|
||||
count: 10,
|
||||
roundType: roundTypeOptions?.value?.[0].value || '',
|
||||
roundType: roundTypeOptions?.value?.[0]?.value || 0,
|
||||
})
|
||||
|
||||
function handleOpenAddModal() {
|
||||
newRoundForm.value = {
|
||||
name: `第${(props.modelValue?.rounds?.length || 0) + 1}轮 星·诗词大会`,
|
||||
count: 10,
|
||||
roundType: roundTypeOptions?.value?.[0].value || '',
|
||||
roundType: roundTypeOptions?.value?.[0]?.value || 0,
|
||||
}
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
function handleConfirmAdd() {
|
||||
if (!newRoundForm.value.name) {
|
||||
window.$message?.warning('请输入环节名称')
|
||||
return
|
||||
}
|
||||
addRound(newRoundForm.value.name, newRoundForm.value.count, newRoundForm.value.roundType as Api.Competition.CompetitionRoundType)
|
||||
addRound(undefined, newRoundForm.value.count, newRoundForm.value.roundType as Api.Competition.CompetitionRoundType)
|
||||
showAddModal.value = false
|
||||
}
|
||||
|
||||
@ -123,8 +120,11 @@ async function getAllTemplates() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getAllQuestions()
|
||||
getAllTemplates()
|
||||
if (!isDataLoaded.value) {
|
||||
getAllQuestions()
|
||||
getAllTemplates()
|
||||
isDataLoaded.value = true
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({ validate, reset })
|
||||
@ -202,10 +202,9 @@ defineExpose({ validate, reset })
|
||||
{{ roundTypeOptions.find((item) => item.value === round.roundType)?.label || '未知类型' }}
|
||||
</NTag>
|
||||
<div class="flex items-center gap-3">
|
||||
<NInput
|
||||
v-model:value="round.name" placeholder="请输入环节名称" :bordered="false"
|
||||
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="w-80 truncate text-xl text-gray-800 font-bold">
|
||||
{{ round.name }}
|
||||
</div>
|
||||
<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"
|
||||
@ -251,6 +250,7 @@ defineExpose({ validate, reset })
|
||||
<VueDraggable
|
||||
v-model="round.questions" :animation="300" handle=".drag-handle" class="flex flex-col gap-3"
|
||||
ghost-class="ghost"
|
||||
@end="(evt) => handleDragEnd(roundIndex, evt)"
|
||||
>
|
||||
<div
|
||||
v-for="(item, qIdx) in round.questions" :key="item.id"
|
||||
@ -268,7 +268,8 @@ defineExpose({ validate, reset })
|
||||
<span
|
||||
class="w-6 flex-shrink-0 text-center text-sm text-gray-300 font-bold transition-colors group-hover/item:text-blue-500"
|
||||
>
|
||||
{{ String(qIdx + 1).padStart(2, '0') }}
|
||||
<!-- {{ String(qIdx + 1).padStart(2, '0') }} - -->
|
||||
<NTag type="primary" class="!text-blue-500">{{ item.QuestionID }}</NTag>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -277,7 +278,7 @@ defineExpose({ validate, reset })
|
||||
<icon-ic-outline-title class="text-gray-400" />
|
||||
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">标题:</span>
|
||||
<NInput
|
||||
v-model:value="item.title" class="flex-1 !bg-transparent" size="small" :bordered="false"
|
||||
v-model:value="item.ActitvityQuestionName" class="flex-1 !bg-transparent" size="small" :bordered="false"
|
||||
placeholder="请输入题目标题"
|
||||
/>
|
||||
</div>
|
||||
@ -297,15 +298,16 @@ defineExpose({ validate, reset })
|
||||
<!-- 题型 -->
|
||||
<div class="col-span-4">
|
||||
<NSelect
|
||||
v-model:value="item.questionId" :options="questionOptions" placeholder="请选择题目类型" size="small"
|
||||
v-model:value="item.QuestionID" :options="questionOptions" placeholder="请选择题目类型" size="small"
|
||||
class="font-medium"
|
||||
@update:value="() => handleTypeChange(roundIndex)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- UItype -->
|
||||
<div class="col-span-4">
|
||||
<NSelect
|
||||
v-model:value="item.uiType" :options="uiTypeOptions" placeholder="请选择UI类型" size="small"
|
||||
v-model:value="item.UIType" :options="uiTypeOptions" placeholder="请选择UI类型" size="small"
|
||||
class="font-medium"
|
||||
/>
|
||||
</div>
|
||||
@ -313,7 +315,7 @@ defineExpose({ validate, reset })
|
||||
<!-- templateId -->
|
||||
<div class="col-span-4">
|
||||
<NSelect
|
||||
v-model:value="item.templateId" :options="templateIdOptions" placeholder="请选择模板" size="small"
|
||||
v-model:value="item.TemplateID" :options="templateIdOptions" placeholder="请选择模板" size="small"
|
||||
class="font-medium"
|
||||
/>
|
||||
</div>
|
||||
@ -323,7 +325,7 @@ defineExpose({ validate, reset })
|
||||
<icon-ic-outline-timer class="text-gray-400" />
|
||||
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">答题时间:</span>
|
||||
<NSelect
|
||||
v-model:value="item.time" :options="timeOptions" class="flex-1 !bg-transparent" size="small"
|
||||
v-model:value="item.QuestionTime" :options="timeOptions" class="flex-1 !bg-transparent" size="small"
|
||||
:bordered="false"
|
||||
/>
|
||||
<span class="text-xs text-gray-400 font-bold">S</span>
|
||||
@ -334,7 +336,7 @@ defineExpose({ validate, reset })
|
||||
<icon-streamline-sharp:type-area-remix class="text-gray-400" />
|
||||
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">分数规则:</span>
|
||||
<NSelect
|
||||
v-model:value="item.scoreType" :options="scoreTypeOptions as unknown as SelectOption[]"
|
||||
v-model:value="item.QuestionRule" :options="scoreTypeOptions as unknown as SelectOption[]"
|
||||
class="flex-1 !bg-transparent" size="small" :bordered="false"
|
||||
/>
|
||||
</div>
|
||||
@ -344,7 +346,7 @@ defineExpose({ validate, reset })
|
||||
<icon-ic-round-star-border class="text-gray-400" />
|
||||
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">分数:</span>
|
||||
<NInputNumber
|
||||
v-model:value="item.score" :min="0" :show-button="false" class="flex-1 !bg-transparent"
|
||||
v-model:value="item.Point" :min="0" :show-button="false" class="flex-1 !bg-transparent"
|
||||
size="small" :bordered="false" placeholder="0"
|
||||
/>
|
||||
<span class="text-xs text-gray-400 font-bold">分</span>
|
||||
@ -374,9 +376,6 @@ defineExpose({ validate, reset })
|
||||
</div>
|
||||
|
||||
<NForm size="large">
|
||||
<NFormItem label="环节/题包名称">
|
||||
<NInput v-model:value="newRoundForm.name" placeholder="例如:第一轮 星·诗词大会" />
|
||||
</NFormItem>
|
||||
<NFormItem label="环节/题包类型">
|
||||
<NSelect
|
||||
v-model:value="newRoundForm.roundType" :options="roundTypeOptions as unknown as SelectOption[]"
|
||||
|
||||
@ -3,12 +3,15 @@ import { ref, watch } from 'vue'
|
||||
|
||||
export interface BasicInfoModel {
|
||||
name: string
|
||||
subTitle?: string
|
||||
startTime: string | null
|
||||
endTime: string | null
|
||||
groupCount: number
|
||||
teamCount: number
|
||||
poster: string
|
||||
roomId: number | null
|
||||
activityContent: string
|
||||
extraTimeContent: string
|
||||
}
|
||||
|
||||
export function useBasicInfo(props: any, emit: any) {
|
||||
@ -17,12 +20,15 @@ export function useBasicInfo(props: any, emit: any) {
|
||||
watch(() => props.modelValue, (val) => {
|
||||
const newForm: BasicInfoModel = {
|
||||
name: val.name,
|
||||
subTitle: val.subTitle,
|
||||
startTime: val.startTime,
|
||||
endTime: val.endTime,
|
||||
groupCount: val.groupCount,
|
||||
teamCount: val.teamCount,
|
||||
poster: val.poster,
|
||||
roomId: val.roomId,
|
||||
activityContent: val.activityContent,
|
||||
extraTimeContent: val.extraTimeContent,
|
||||
}
|
||||
|
||||
if (JSON.stringify(newForm) !== JSON.stringify(formData.value)) {
|
||||
@ -80,6 +86,14 @@ export function useBasicInfo(props: any, emit: any) {
|
||||
window.$message?.error('每组队伍数量必须为正整数')
|
||||
return false
|
||||
}
|
||||
if (!formData.value.activityContent || formData.value.activityContent === '<p><br></p>') {
|
||||
window.$message?.error('请输入普通赛事规则')
|
||||
return false
|
||||
}
|
||||
if (!formData.value.extraTimeContent || formData.value.extraTimeContent === '<p><br></p>') {
|
||||
window.$message?.error('请输入加时赛规则')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@ -87,12 +101,15 @@ export function useBasicInfo(props: any, emit: any) {
|
||||
function reset() {
|
||||
formData.value = {
|
||||
name: '阅读之星年度总决赛',
|
||||
subTitle: '',
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
groupCount: 10,
|
||||
teamCount: 4,
|
||||
poster: '',
|
||||
roomId: null,
|
||||
activityContent: '',
|
||||
extraTimeContent: '',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import { exportToExcel, readExcel } from '@sa/utils'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
export function useGroupManagement(props: any) {
|
||||
const groups = ref<Array<{ name: string, id: string, teams: Array<{ name: string }> }>>([])
|
||||
const groups = ref<Array<{ name: string, id: string, teams: Array<{ name: string, headImg: string }> }>>([])
|
||||
|
||||
// 根据分组数量和队伍数量生成分组数据
|
||||
function generateGroups() {
|
||||
@ -13,33 +13,48 @@ export function useGroupManagement(props: any) {
|
||||
if (!groupCount || groupCount <= 0)
|
||||
return
|
||||
|
||||
// 优先使用现有的 groups.value,如果为空则尝试使用 props 中的初始数据
|
||||
const oldGroups = groups.value.length > 0
|
||||
? groups.value
|
||||
: (props.modelValue?.groupManagement || [])
|
||||
// 获取当前已有的分组数据(优先用 groups.value,其次用 props 中的初始数据)
|
||||
const currentGroups = groups.value.length > 0 ? groups.value : (props.modelValue?.groupManagement || [])
|
||||
|
||||
groups.value = Array.from({ length: groupCount }).map((_, index) => {
|
||||
const i = index + 1
|
||||
const idStr = String(i).padStart(2, '0')
|
||||
const existingGroup = oldGroups[index]
|
||||
// 如果当前分组数量和配置一致,且每个分组的队伍数量也一致,则无需重新生成
|
||||
// 这能有效防止因引用变化导致的无限循环
|
||||
const isCountMatch = currentGroups.length === groupCount
|
||||
const isTeamCountMatch = currentGroups.every((g: any) => g.teams && g.teams.length === teamCount)
|
||||
|
||||
// 复用现有名称或生成默认名称
|
||||
const groupName = existingGroup ? existingGroup.name : `第${i}组`
|
||||
|
||||
// 生成或复用队伍数据
|
||||
const teams = Array.from({ length: teamCount }).map((__, tIndex) => {
|
||||
const existingTeam = existingGroup?.teams?.[tIndex]
|
||||
return {
|
||||
name: existingTeam ? existingTeam.name : '',
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
name: groupName,
|
||||
id: `G${idStr}`,
|
||||
teams,
|
||||
if (isCountMatch && isTeamCountMatch && currentGroups.length > 0) {
|
||||
// 即使数量匹配,如果 groups.value 为空(初始化时),还是需要赋值一次
|
||||
if (groups.value.length === 0) {
|
||||
groups.value = JSON.parse(JSON.stringify(currentGroups))
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 增量更新逻辑:保留已有数据,仅裁剪或新增
|
||||
const newGroups = []
|
||||
|
||||
for (let i = 0; i < groupCount; i++) {
|
||||
const existingGroup = currentGroups[i]
|
||||
const groupName = existingGroup ? existingGroup.name : `第${i + 1}组`
|
||||
const groupId = `G${String(i + 1).padStart(2, '0')}`
|
||||
|
||||
// 处理队伍
|
||||
const newTeams = []
|
||||
for (let t = 0; t < teamCount; t++) {
|
||||
const existingTeam = existingGroup?.teams?.[t]
|
||||
newTeams.push({
|
||||
name: existingTeam ? existingTeam.name : '',
|
||||
headImg: existingTeam?.headImg || '',
|
||||
})
|
||||
}
|
||||
|
||||
newGroups.push({
|
||||
name: groupName,
|
||||
id: groupId,
|
||||
teams: newTeams,
|
||||
})
|
||||
}
|
||||
|
||||
groups.value = newGroups
|
||||
}
|
||||
|
||||
watch(() => [props.config?.groupCount, props.config?.teamCount], generateGroups, { immediate: true, deep: true })
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
import { computed } from 'vue'
|
||||
import { roundTypeOptions, scoreTypeOptions } from '@/constants/business'
|
||||
import { computed, nextTick } from 'vue'
|
||||
import { useDict } from '@/hooks/business/useDict'
|
||||
|
||||
export interface QuestionItem {
|
||||
const { options: roundTypeOptions } = useDict('round_type')
|
||||
const { options: scoreTypeOptions } = useDict('score_type')
|
||||
|
||||
export interface QuestionItem extends Partial<Api.Competition.QuestionListRecord> {
|
||||
id: string
|
||||
questionId: string | null
|
||||
score: number
|
||||
time: number
|
||||
title: string
|
||||
scoreType: Api.Competition.QuestionScoreType
|
||||
uiType?: string
|
||||
templateId?: number
|
||||
}
|
||||
|
||||
export interface RoundModel {
|
||||
@ -28,7 +24,7 @@ export function useQuestionConfig(props: any) {
|
||||
const round = props.modelValue?.rounds?.[roundIndex]
|
||||
if (!round)
|
||||
return 0
|
||||
return round.questions.reduce((sum: number, item: any) => sum + (Number(item.score) || 0), 0)
|
||||
return round.questions.reduce((sum: number, item: any) => sum + (Number(item.Point) || 0), 0)
|
||||
}
|
||||
|
||||
// 计算每个轮次的时长
|
||||
@ -36,7 +32,7 @@ export function useQuestionConfig(props: any) {
|
||||
const round = props.modelValue?.rounds?.[roundIndex]
|
||||
if (!round)
|
||||
return 0
|
||||
return round.questions.reduce((sum: number, item: any) => sum + (Number(item.time) || 0), 0)
|
||||
return round.questions.reduce((sum: number, item: any) => sum + (Number(item.QuestionTime) || 0), 0)
|
||||
}
|
||||
|
||||
// 总计统计
|
||||
@ -44,43 +40,157 @@ export function useQuestionConfig(props: any) {
|
||||
const rounds = props.modelValue?.rounds || []
|
||||
return rounds.reduce((acc: any, round: any) => {
|
||||
acc.count += round.questions.length
|
||||
acc.score += round.questions.reduce((sum: number, item: any) => sum + (Number(item.score) || 0), 0)
|
||||
acc.time += round.questions.reduce((sum: number, item: any) => sum + (Number(item.time) || 0), 0)
|
||||
acc.score += round.questions.reduce((sum: number, item: any) => sum + (Number(item.Point) || 0), 0)
|
||||
acc.time += round.questions.reduce((sum: number, item: any) => sum + (Number(item.QuestionTime) || 0), 0)
|
||||
return acc
|
||||
}, { count: 0, score: 0, time: 0 })
|
||||
})
|
||||
|
||||
// 新增轮次
|
||||
// 拖拽结束处理:根据拖动意图重新分组
|
||||
function handleDragEnd(roundIndex: number, event: any) {
|
||||
nextTick(() => {
|
||||
const round = props.modelValue.rounds[roundIndex]
|
||||
if (!round || !round.questions)
|
||||
return
|
||||
|
||||
const { newIndex } = event
|
||||
const questions = round.questions
|
||||
const draggedItem = questions[newIndex]
|
||||
|
||||
if (!draggedItem)
|
||||
return
|
||||
|
||||
// 统一转字符串进行比较,兼容 number/string
|
||||
const targetQid = String(draggedItem.QuestionID)
|
||||
|
||||
// 1. 分离目标组和其他元素
|
||||
const targetItems = questions.filter((q: any) => String(q.QuestionID) === targetQid)
|
||||
const nonTargetItems = questions.filter((q: any) => String(q.QuestionID) !== targetQid)
|
||||
|
||||
// 2. 计算插入位置
|
||||
// 我们需要找到拖拽元素在非目标元素中的“相对位置”
|
||||
// 如果它被拖到了同组元素中间,我们应该将其归并到该组的后面(保持组的完整性)
|
||||
|
||||
let prevNonTargetIndex = -1
|
||||
|
||||
// 从 newIndex 向前寻找第一个非目标元素
|
||||
for (let i = newIndex - 1; i >= 0; i--) {
|
||||
if (String(questions[i].QuestionID) !== targetQid) {
|
||||
// 找到这个非目标元素在 nonTargetItems 中的索引
|
||||
const prevItem = questions[i]
|
||||
prevNonTargetIndex = nonTargetItems.indexOf(prevItem)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let insertionIndex = 0
|
||||
|
||||
if (prevNonTargetIndex === -1) {
|
||||
// 如果前面没有非目标元素,说明插到了最前面
|
||||
insertionIndex = 0
|
||||
}
|
||||
else {
|
||||
// 如果前面有非目标元素,检查它所属的组
|
||||
const prevItem = nonTargetItems[prevNonTargetIndex]
|
||||
const prevGroupId = String(prevItem.QuestionID)
|
||||
|
||||
// 找到该组在 nonTargetItems 中的最后一个元素的位置
|
||||
// 这样做是为了确保如果插入到了某组中间,会跳过该组剩余元素,插在该组之后
|
||||
let lastIndexInGroup = prevNonTargetIndex
|
||||
for (let i = prevNonTargetIndex + 1; i < nonTargetItems.length; i++) {
|
||||
if (String(nonTargetItems[i].QuestionID) === prevGroupId) {
|
||||
lastIndexInGroup = i
|
||||
}
|
||||
else {
|
||||
// 遇到不同组,停止
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
insertionIndex = lastIndexInGroup + 1
|
||||
}
|
||||
|
||||
// 3. 重组数组
|
||||
round.questions = [
|
||||
...nonTargetItems.slice(0, insertionIndex),
|
||||
...targetItems,
|
||||
...nonTargetItems.slice(insertionIndex),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// 题目类型变更处理:按首次出现顺序分组
|
||||
function handleTypeChange(roundIndex: number) {
|
||||
const round = props.modelValue.rounds[roundIndex]
|
||||
if (!round || !round.questions)
|
||||
return
|
||||
|
||||
const questions = round.questions
|
||||
const groups = new Map<string, any[]>()
|
||||
const groupOrder: string[] = []
|
||||
|
||||
// 记录分组和顺序
|
||||
for (const q of questions) {
|
||||
const qid = String(q.QuestionID)
|
||||
if (!groups.has(qid)) {
|
||||
groups.set(qid, [])
|
||||
groupOrder.push(qid)
|
||||
}
|
||||
groups.get(qid)?.push(q)
|
||||
}
|
||||
|
||||
// 重组
|
||||
const newQuestions: any[] = []
|
||||
for (const qid of groupOrder) {
|
||||
const group = groups.get(qid)
|
||||
if (group) {
|
||||
newQuestions.push(...group)
|
||||
}
|
||||
}
|
||||
|
||||
round.questions = newQuestions
|
||||
}
|
||||
|
||||
// 新增轮次(现在逻辑为:根据 roundType 添加题目到对应分组,如果分组不存在则创建)
|
||||
function addRound(customName?: string, customCount?: number, roundType?: Api.Competition.CompetitionRoundType) {
|
||||
if (!props.modelValue.rounds) {
|
||||
props.modelValue.rounds = []
|
||||
}
|
||||
const index = props.modelValue.rounds.length + 1
|
||||
const count = typeof customCount === 'number' ? customCount : 10
|
||||
const name = typeof customName === 'string' ? customName : `第${index}轮:比赛环节`
|
||||
|
||||
props.modelValue.rounds.push({
|
||||
id: `round_${Date.now()}`,
|
||||
name,
|
||||
roundType: roundType || roundTypeOptions[0].value,
|
||||
questions: Array.from({ length: count }).map(() => ({
|
||||
id: generateId(),
|
||||
questionId: null,
|
||||
score: 5,
|
||||
time: 30,
|
||||
title: '',
|
||||
scoreType: scoreTypeOptions[0].value,
|
||||
})),
|
||||
})
|
||||
const type = roundType || roundTypeOptions.value?.[0]?.value || 0
|
||||
const count = typeof customCount === 'number' ? customCount : 10
|
||||
|
||||
// 查找是否已存在该类型的轮次
|
||||
const existingRound = props.modelValue.rounds.find((r: any) => r.roundType === type)
|
||||
|
||||
const newQuestions = Array.from({ length: count }).map(() => ({
|
||||
id: generateId(),
|
||||
QuestionID: null,
|
||||
Point: 5,
|
||||
QuestionTime: 30,
|
||||
ActitvityQuestionName: '',
|
||||
QuestionRule: scoreTypeOptions.value?.[0]?.value || 0,
|
||||
}))
|
||||
|
||||
if (existingRound) {
|
||||
existingRound.questions.push(...newQuestions)
|
||||
handleTypeChange(props.modelValue.rounds.indexOf(existingRound))
|
||||
}
|
||||
else {
|
||||
// 获取类型名称作为默认名称
|
||||
const typeLabel = roundTypeOptions.value?.find(opt => opt.value === type)?.label || '未知类型'
|
||||
props.modelValue.rounds.push({
|
||||
id: `round_${Date.now()}`,
|
||||
name: typeLabel,
|
||||
roundType: type,
|
||||
questions: newQuestions, // 新创建时全是空的,不需要排序,或者排一下也无妨
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 删除轮次
|
||||
function removeRound(index: number) {
|
||||
props.modelValue.rounds.splice(index, 1)
|
||||
// 重命名剩余轮次
|
||||
props.modelValue.rounds.forEach((round: RoundModel, idx: number) => {
|
||||
round.name = round.name.replace(/第\d+轮/, `第${idx + 1}轮`)
|
||||
})
|
||||
}
|
||||
|
||||
// 新增题目
|
||||
@ -89,12 +199,13 @@ export function useQuestionConfig(props: any) {
|
||||
if (round) {
|
||||
round.questions.push({
|
||||
id: generateId(),
|
||||
value: null,
|
||||
score: 5,
|
||||
time: 30,
|
||||
title: '',
|
||||
scoreType: scoreTypeOptions[0].value,
|
||||
QuestionID: 0,
|
||||
Point: 5,
|
||||
QuestionTime: 30,
|
||||
ActitvityQuestionName: '',
|
||||
QuestionRule: 1,
|
||||
})
|
||||
handleTypeChange(roundIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@ -123,31 +234,31 @@ export function useQuestionConfig(props: any) {
|
||||
const question = round.questions[j]
|
||||
const questionPrefix = `${round.name} 第 ${j + 1} 题`
|
||||
|
||||
if (!question.questionId) {
|
||||
if (question.QuestionID === undefined || question.QuestionID === null || question.QuestionID === '') {
|
||||
window.$message?.error(`${questionPrefix}未选择题型`)
|
||||
return false
|
||||
}
|
||||
if (!question.uiType) {
|
||||
if (question.UIType === undefined || question.UIType === null || question.UIType === '') {
|
||||
window.$message?.error(`${questionPrefix}未选择UI类型`)
|
||||
return false
|
||||
}
|
||||
if (!question.templateId) {
|
||||
if (question.TemplateID === undefined || question.TemplateID === null || question.TemplateID === '') {
|
||||
window.$message?.error(`${questionPrefix}未选择模板`)
|
||||
return false
|
||||
}
|
||||
if (!question.time) {
|
||||
if (question.QuestionTime === undefined || question.QuestionTime === null || question.QuestionTime === '') {
|
||||
window.$message?.error(`${questionPrefix}未设置答题时间`)
|
||||
return false
|
||||
}
|
||||
if (!question.scoreType) {
|
||||
if (question.QuestionRule === undefined || question.QuestionRule === null || question.QuestionRule === '') {
|
||||
window.$message?.error(`${questionPrefix}未设置分数规则`)
|
||||
return false
|
||||
}
|
||||
if (!question.title) {
|
||||
if (question.ActitvityQuestionName === undefined || question.ActitvityQuestionName === null || question.ActitvityQuestionName === '') {
|
||||
window.$message?.error(`${questionPrefix}未填写标题`)
|
||||
return false
|
||||
}
|
||||
if (question.score === null || question.score === undefined) {
|
||||
if (question.Point === null || question.Point === undefined) {
|
||||
window.$message?.error(`${questionPrefix}未设置分数`)
|
||||
return false
|
||||
}
|
||||
@ -169,6 +280,8 @@ export function useQuestionConfig(props: any) {
|
||||
addRound,
|
||||
removeRound,
|
||||
addQuestion,
|
||||
handleDragEnd,
|
||||
handleTypeChange,
|
||||
removeQuestion,
|
||||
validate,
|
||||
reset,
|
||||
|
||||
@ -1,89 +1,354 @@
|
||||
<!-- eslint-disable no-console -->
|
||||
<script setup lang="ts">
|
||||
import { NButton, NTag, useMessage } from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
import { NButton, NTag } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import PageHeader from '@/components/common/page-header.vue'
|
||||
import { PublishStatus } from '@/enum/business'
|
||||
import { useDict } from '@/hooks/business/useDict'
|
||||
import { useRouterPush } from '@/hooks/common/router'
|
||||
|
||||
import {
|
||||
fetchDeleteActivity,
|
||||
fetchGetActivityDetail,
|
||||
fetchGetQuestionList,
|
||||
fetchGetTeamListByGroupId,
|
||||
fetchUpdateActivity,
|
||||
fetchUpdatePublishStatus,
|
||||
fetchUpdateQuestion,
|
||||
fetchUpdateTeamList,
|
||||
} from '@/service/api/competition'
|
||||
import BasicInfoCard from './modules/BasicInfoCard.vue'
|
||||
import QuestionConfigCard from './modules/QuestionConfigCard.vue'
|
||||
import SideMenu from './modules/SideMenu.vue'
|
||||
|
||||
import TeamListCard from './modules/TeamListCard.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const isEdit = ref(false)
|
||||
const route = useRoute()
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
const competitionId = route.query.id
|
||||
const { options: publishStatusDict } = useDict('publish_status')
|
||||
|
||||
function handleSave() {
|
||||
// 模拟保存逻辑
|
||||
isEdit.value = false
|
||||
message.success('保存成功')
|
||||
const { routerBack } = useRouterPush()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const activeEditModule = ref<string | null>(null) // 'basic' | 'team' | 'question'
|
||||
|
||||
const currentGroupId = ref(0)
|
||||
const basicInfoCardRef = ref()
|
||||
const teamListCardRef = ref()
|
||||
const questionConfigCardRef = ref()
|
||||
|
||||
/** 规范化路由 Query 的 Id 字段为字符串 */
|
||||
const competitionId = computed<string>(() => {
|
||||
const q = route.query.Id
|
||||
const v = Array.isArray(q) ? q[0] : q
|
||||
return (v ?? '') as string
|
||||
})
|
||||
|
||||
/** 基础信息数据 */
|
||||
const basicInfo = ref<Api.Competition.ActivityDetail>({
|
||||
Id: 0,
|
||||
RoomID: 0,
|
||||
Name: '',
|
||||
StartTime: '',
|
||||
EndTime: '',
|
||||
Teams: 0,
|
||||
TeamGroupNumber: 0,
|
||||
PublishStatus: 0,
|
||||
BackgroundImg: '',
|
||||
CreatedTime: '',
|
||||
})
|
||||
|
||||
/** 队伍列表 */
|
||||
const teamList = ref<Array<Api.Competition.TeamListRecord>>([])
|
||||
|
||||
/** 题目配置 */
|
||||
const questionsList = ref<Array<Api.Competition.QuestionListRecord>>([])
|
||||
|
||||
/** 监听竞赛ID变化并拉取详情 */
|
||||
watch(competitionId, async (val: string) => {
|
||||
if (val) {
|
||||
const id = Number(val)
|
||||
/** 并行获取详情数据 */
|
||||
await getActivityDetail(id)
|
||||
await getQuestionList(id)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
/** 获取活动基础详情 */
|
||||
async function getActivityDetail(id: number) {
|
||||
const { response, error } = await fetchGetActivityDetail(id)
|
||||
if (!error) {
|
||||
basicInfo.value = response?.data?.data || {}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('获取活动基础详情', response.data.data)
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击分组 */
|
||||
async function handleChangeGroup(id: number) {
|
||||
currentGroupId.value = id
|
||||
// 拉取当前分组的队伍列表
|
||||
await fetchTeamListByGroupId(id)
|
||||
}
|
||||
|
||||
/** 根据组id查询队伍列表 */
|
||||
async function fetchTeamListByGroupId(GroupID: number) {
|
||||
const { data, error } = await fetchGetTeamListByGroupId(GroupID)
|
||||
if (!error) {
|
||||
teamList.value = data?.data || []
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('获取队伍列表', data?.data || [])
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取活动题目列表 */
|
||||
async function getQuestionList(id: number) {
|
||||
const { data, error } = await fetchGetQuestionList(id)
|
||||
if (!error) {
|
||||
questionsList.value = data?.data || []
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('获取题目列表', data?.data || [])
|
||||
}
|
||||
}
|
||||
|
||||
// 点击编辑模块
|
||||
/** 请求编辑模块 */
|
||||
function handleRequestEdit(moduleName: string) {
|
||||
if (basicInfo.value.PublishStatus === PublishStatus.Processing || basicInfo.value.PublishStatus === PublishStatus.Finished) {
|
||||
window.$message?.warning('进行中或已结束的赛事无法编辑')
|
||||
return
|
||||
}
|
||||
if (activeEditModule.value && activeEditModule.value !== moduleName) {
|
||||
window.$message?.warning('请先保存或取消当前正在编辑的模块')
|
||||
return
|
||||
}
|
||||
activeEditModule.value = moduleName
|
||||
}
|
||||
|
||||
/** 点击取消编辑 */
|
||||
function handleCancelEdit() {
|
||||
activeEditModule.value = null
|
||||
// Refresh data to reset forms
|
||||
if (competitionId.value) {
|
||||
const id = Number(competitionId.value)
|
||||
getActivityDetail(id)
|
||||
getQuestionList(id)
|
||||
if (currentGroupId.value)
|
||||
fetchTeamListByGroupId(currentGroupId.value)
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击保存基础信息 */
|
||||
async function handleSaveBasic() {
|
||||
const basicForm = basicInfoCardRef.value?.form
|
||||
if (!basicForm)
|
||||
return
|
||||
|
||||
const params: Api.Competition.ActivityDetail = {
|
||||
...basicInfo.value,
|
||||
...basicForm,
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('基础信息保存参数', params)
|
||||
const { error } = await fetchUpdateActivity(params)
|
||||
if (!error) {
|
||||
window.$message?.success('基础信息保存成功')
|
||||
activeEditModule.value = null
|
||||
getActivityDetail(Number(competitionId.value))
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击保存队伍信息 */
|
||||
async function handleSaveTeam() {
|
||||
const teams = teamListCardRef.value?.localTeams
|
||||
if (!teams)
|
||||
return
|
||||
|
||||
const params: Api.Competition.CreateTeamListRequest[] = teams.map((t: Api.Competition.TeamListRecord) => ({
|
||||
id: t.Id,
|
||||
mainId: t.MainId,
|
||||
number: t.Number,
|
||||
name: t.Name,
|
||||
penSerial: t.PenSerial,
|
||||
nameList: t.NameList,
|
||||
schoolName: t.SchoolName,
|
||||
teamGroupId: t.TeamGroupId,
|
||||
headImg: t.HeadImg,
|
||||
}))
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('新增队伍信息的params', params)
|
||||
const { error } = await fetchUpdateTeamList(params)
|
||||
if (!error) {
|
||||
window.$message?.success('队伍信息保存成功')
|
||||
activeEditModule.value = null
|
||||
if (currentGroupId.value)
|
||||
fetchTeamListByGroupId(currentGroupId.value)
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击保存题目配置 */
|
||||
async function handleSaveQuestion() {
|
||||
const component = questionConfigCardRef.value
|
||||
if (!component)
|
||||
return
|
||||
|
||||
const valid = await component.validate()
|
||||
if (!valid)
|
||||
return
|
||||
|
||||
const modelValue = component.modelValue
|
||||
console.log('原始题目配置', modelValue)
|
||||
// 扁平化题目配置,将 rounds 中的 questions 合并到 flatQuestions 中
|
||||
const flatQuestions: Api.Competition.QuestionListRecord[] = []
|
||||
modelValue.rounds.forEach((r: any) => {
|
||||
r.questions.forEach((q: any, index: number) => {
|
||||
flatQuestions.push({
|
||||
ActitvityQuestionName: q.ActitvityQuestionName || '',
|
||||
ActivityID: Number(competitionId.value),
|
||||
ID: q.ID || 0,
|
||||
Point: q.Point,
|
||||
QuestionID: q.QuestionID ? Number(q.QuestionID) : 0,
|
||||
QuestionIndex: index,
|
||||
QuestionRule: q.QuestionRule ? Number(q.QuestionRule) : 0,
|
||||
QuestionSubTitle: '',
|
||||
QuestionTime: q.QuestionTime,
|
||||
RoundType: Number(r.roundType),
|
||||
TemplateID: q.TemplateID || 0,
|
||||
UIType: q.UIType,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// 根据 questionID 降序排序 (例如: 54455 -> 55544)
|
||||
flatQuestions.sort((a, b) => Number(b.QuestionID) - Number(a.QuestionID))
|
||||
|
||||
// 重新计算 questionIndex
|
||||
flatQuestions.forEach((item, index) => {
|
||||
item.QuestionIndex = index
|
||||
})
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('新增题目配置的params', flatQuestions)
|
||||
const { error } = await fetchUpdateQuestion(flatQuestions)
|
||||
if (!error) {
|
||||
window.$message?.success('题目配置保存成功')
|
||||
activeEditModule.value = null
|
||||
getQuestionList(Number(competitionId.value))
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除赛事 */
|
||||
async function handleDelete() {
|
||||
window.$dialog?.warning({
|
||||
title: '删除确认',
|
||||
content: '确定要删除该赛事吗?此操作无法撤销。',
|
||||
positiveText: '确定删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
const { error } = await fetchDeleteActivity(Number(competitionId.value))
|
||||
if (!error) {
|
||||
window.$message?.success('删除成功')
|
||||
routerBack()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 立即发布 */
|
||||
async function handlePublish() {
|
||||
window.$dialog?.info({
|
||||
title: '发布确认',
|
||||
content: '确定要立即发布该赛事吗?发布后将对外可见。',
|
||||
positiveText: '立即发布',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
const { error } = await fetchUpdatePublishStatus(Number(competitionId.value), PublishStatus.Published)
|
||||
if (!error) {
|
||||
window.$message?.success('发布成功')
|
||||
getActivityDetail(Number(competitionId.value))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const publishStatusLabel = computed(() => {
|
||||
if (!publishStatusDict.value)
|
||||
return ''
|
||||
return publishStatusDict.value.find((item: any) => item.value === basicInfo.value.PublishStatus)?.label || ''
|
||||
})
|
||||
|
||||
const isUnpublished = computed(() => basicInfo.value.PublishStatus === PublishStatus.Unpublished)
|
||||
// const isPublished = computed(() => basicInfo.value.PublishStatus === PublishStatus.Published)
|
||||
const statusConfig = computed(() => {
|
||||
const status = basicInfo.value.PublishStatus
|
||||
switch (status) {
|
||||
case PublishStatus.Unpublished:
|
||||
return { bgClass: 'bg-unpublished-100 text-unpublished-600' }
|
||||
case PublishStatus.Published:
|
||||
return { bgClass: 'bg-published-100 text-published-600' }
|
||||
case PublishStatus.Processing:
|
||||
return { bgClass: 'bg-processing-100 text-processing-600' }
|
||||
case PublishStatus.Finished:
|
||||
return { bgClass: 'bg-finished-100 text-finished-600' }
|
||||
default:
|
||||
return { bgClass: 'bg-unpublished-100 text-unpublished-600' }
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex gap-4 overflow-hidden bg-gray-50/50 p-4">
|
||||
<!-- 左侧分组导航 -->
|
||||
<div class="h-full w-64 flex flex-col flex-shrink-0 overflow-hidden rounded-xl bg-white shadow-sm">
|
||||
<SideMenu />
|
||||
</div>
|
||||
<div class="h-full flex flex-col overflow-hidden bg-gray-50/50">
|
||||
<!-- 顶部 Header -->
|
||||
<PageHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-500">状态:</span>
|
||||
<NTag size="small" :bordered="false" :class="statusConfig.bgClass">
|
||||
{{ publishStatusLabel }}
|
||||
</NTag>
|
||||
</div>
|
||||
<template #action>
|
||||
<NButton v-if="isUnpublished" type="error" secondary @click="handleDelete">
|
||||
<template #icon>
|
||||
<icon-ic:baseline-delete-forever class="text-3xl" />
|
||||
</template>
|
||||
删除赛事
|
||||
</NButton>
|
||||
<NButton v-if="isUnpublished" type="primary" @click="handlePublish">
|
||||
<template #icon>
|
||||
<icon-ic:round-publish class="text-3xl" />
|
||||
</template>
|
||||
立即发布
|
||||
</NButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- 右侧主要内容区 -->
|
||||
<div class="h-full flex flex-col flex-1 overflow-hidden">
|
||||
<!-- 新增:顶部全局操作栏 -->
|
||||
<PageHeader class="mb-4 flex-shrink-0">
|
||||
<div class="text-sm text-gray-500">
|
||||
当前状态:
|
||||
<NTag :type="isEdit ? 'warning' : 'success'" size="small" :bordered="false">
|
||||
{{ isEdit ? '编辑中' : '预览中' }}
|
||||
</NTag>
|
||||
<!-- 主内容区 -->
|
||||
<div class="flex flex-1 gap-4 overflow-hidden p-4">
|
||||
<!-- 左侧分组导航 -->
|
||||
<div class="h-full w-64 flex flex-col flex-shrink-0 overflow-hidden rounded-xl bg-white shadow-sm">
|
||||
<SideMenu :activity-id="competitionId" :disabled="!!activeEditModule" @change="handleChangeGroup" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧主要内容区 -->
|
||||
<div class="h-full flex flex-col flex-1 overflow-hidden">
|
||||
<!-- 滚动内容区 -->
|
||||
<div class="no-scrollbar flex flex-col flex-1 gap-4 overflow-y-auto">
|
||||
<BasicInfoCard
|
||||
ref="basicInfoCardRef" :is-edit="activeEditModule === 'basic'" :base-info="basicInfo"
|
||||
:publish-status="basicInfo.PublishStatus" @enter-edit="handleRequestEdit('basic')" @save="handleSaveBasic"
|
||||
@cancel="handleCancelEdit"
|
||||
/>
|
||||
<TeamListCard
|
||||
ref="teamListCardRef" :is-edit="activeEditModule === 'team'" :teams="teamList"
|
||||
:publish-status="basicInfo.PublishStatus" :current-group-id="currentGroupId"
|
||||
@enter-edit="handleRequestEdit('team')" @save="handleSaveTeam" @cancel="handleCancelEdit"
|
||||
/>
|
||||
<QuestionConfigCard
|
||||
ref="questionConfigCardRef" :is-edit="activeEditModule === 'question'"
|
||||
:publish-status="basicInfo.PublishStatus" :questions="questionsList"
|
||||
@enter-edit="handleRequestEdit('question')" @save="handleSaveQuestion" @cancel="handleCancelEdit"
|
||||
/>
|
||||
</div>
|
||||
<template #action>
|
||||
<div class="flex gap-3">
|
||||
<NButton type="error" secondary>
|
||||
<template #icon>
|
||||
<icon-ic-baseline-delete class="text-icon" />
|
||||
</template>
|
||||
删除赛事
|
||||
</NButton>
|
||||
|
||||
<div class="w-1px bg-gray-200" />
|
||||
|
||||
<NButton v-if="!isEdit" type="primary" secondary @click="isEdit = true">
|
||||
<template #icon>
|
||||
<icon-ic-baseline-edit class="text-icon" />
|
||||
</template>
|
||||
进入编辑模式
|
||||
</NButton>
|
||||
|
||||
<template v-else>
|
||||
<NButton secondary @click="isEdit = false">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton type="primary" @click="handleSave">
|
||||
<template #icon>
|
||||
<icon-ic-baseline-save class="text-icon" />
|
||||
</template>
|
||||
保存更改
|
||||
</NButton>
|
||||
</template>
|
||||
|
||||
<NButton v-if="!isEdit" type="success">
|
||||
<template #icon>
|
||||
<icon-ic-baseline-rocket-launch class="text-icon" />
|
||||
</template>
|
||||
立即发布
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- 滚动内容区 -->
|
||||
<div class="no-scrollbar flex flex-col flex-1 gap-4 overflow-y-auto">
|
||||
<!-- 将 isEdit 状态传递给子组件 -->
|
||||
<BasicInfoCard :is-edit="isEdit" />
|
||||
<TeamListCard :is-edit="isEdit" />
|
||||
<QuestionConfigCard :is-edit="isEdit" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -93,6 +358,7 @@ function handleSave() {
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
@ -1,80 +1,288 @@
|
||||
<script setup lang="ts">
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { NCard, NDatePicker, NGrid, NGridItem, NInput, NInputNumber } from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
||||
import OssImageUpload from '@/components/common/oss-image-upload/index.vue'
|
||||
import { PublishStatus } from '@/enum/business'
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
|
||||
defineProps<{ isEdit: boolean }>()
|
||||
const props = defineProps<{
|
||||
isEdit: boolean
|
||||
baseInfo: Api.Competition.ActivityDetail
|
||||
publishStatus: number
|
||||
}>()
|
||||
|
||||
const form = ref({
|
||||
name: '第九届阅读之星大赛',
|
||||
dateRange: null,
|
||||
total: 40, // 参赛队伍总数
|
||||
teamPerGroup: 4, // 单组队伍数量
|
||||
promoteCount: 10, // 队伍数量
|
||||
const emit = defineEmits(['enterEdit', 'save', 'cancel'])
|
||||
|
||||
const form = ref<Api.Competition.ActivityDetail>({
|
||||
Id: 0,
|
||||
RoomID: 0,
|
||||
Name: '',
|
||||
StartTime: '',
|
||||
EndTime: '',
|
||||
Teams: 0,
|
||||
TeamGroupNumber: 0,
|
||||
PublishStatus: 0,
|
||||
BackgroundImg: '',
|
||||
CreatedTime: '',
|
||||
ActivityTitle: '',
|
||||
ActivityContent: '',
|
||||
ExtraTimeContent: '',
|
||||
})
|
||||
|
||||
// Editor Logic
|
||||
const editorRef = shallowRef()
|
||||
const mode = 'default'
|
||||
const toolbarConfig = {
|
||||
excludeKeys: ['group-video', 'insertVideo', 'uploadVideo'],
|
||||
}
|
||||
const editorConfig = { placeholder: '请输入具体的比赛参与规则及计分标准...' }
|
||||
const activeTab = ref('normal') // normal or extra
|
||||
|
||||
function handleCreated(editor: any) {
|
||||
editorRef.value = editor
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value
|
||||
if (editor == null)
|
||||
return
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
/** 同步父组件基础信息到本地表单 */
|
||||
watch(
|
||||
() => props.baseInfo,
|
||||
(val) => {
|
||||
if (val) {
|
||||
form.value = { ...val }
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
/** 日期范围计算属性 */
|
||||
const dateRange = computed({
|
||||
get() {
|
||||
if (form.value.StartTime && form.value.EndTime) {
|
||||
return [form.value.StartTime, form.value.EndTime] as [string, string]
|
||||
}
|
||||
return null
|
||||
},
|
||||
set(val: [string, string] | null) {
|
||||
if (val) {
|
||||
form.value.StartTime = val[0]
|
||||
form.value.EndTime = val[1]
|
||||
}
|
||||
else {
|
||||
form.value.StartTime = ''
|
||||
form.value.EndTime = ''
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/** 预览模式下的日期文案 */
|
||||
const previewDateText = computed(() => {
|
||||
if (!form.value.StartTime || !form.value.EndTime) {
|
||||
return { start: '-', end: '-' }
|
||||
}
|
||||
const fmt = (str: string) => str.slice(0, 10)
|
||||
return { start: fmt(form.value.StartTime), end: fmt(form.value.EndTime) }
|
||||
})
|
||||
|
||||
const activeContent = computed(() => {
|
||||
return activeTab.value === 'normal' ? form.value.ActivityContent : form.value.ExtraTimeContent
|
||||
})
|
||||
|
||||
/** 是否显示编辑按钮 */
|
||||
const showEnterEditButton = computed(() => {
|
||||
return !props.isEdit && [PublishStatus.Unpublished, PublishStatus.Published].includes(props.publishStatus)
|
||||
})
|
||||
|
||||
/** 是否显示保存/取消按钮 */
|
||||
const showEditActions = computed(() => props.isEdit)
|
||||
|
||||
defineExpose({ form })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard :bordered="false" class="rounded-xl shadow-sm">
|
||||
<div class="mb-6 flex items-center gap-3 border-l-4 border-blue-600 pl-3">
|
||||
<span class="text-lg text-gray-800 font-bold">基础信息</span>
|
||||
</div>
|
||||
|
||||
<NGrid :x-gap="24" :cols="4" class="py-2">
|
||||
<!-- 赛事名称 -->
|
||||
<NGridItem>
|
||||
<div class="mb-2 text-xs text-gray-400 font-medium">
|
||||
赛事名称
|
||||
</div>
|
||||
<NInput v-if="isEdit" v-model:value="form.name" placeholder="输入名称" />
|
||||
<div v-else class="text-base text-gray-800 font-bold">
|
||||
{{ form.name }}
|
||||
</div>
|
||||
</NGridItem>
|
||||
|
||||
<!-- 起止时间 -->
|
||||
<NGridItem>
|
||||
<div class="mb-2 text-xs text-gray-400 font-medium">
|
||||
起止时间
|
||||
</div>
|
||||
<NDatePicker v-if="isEdit" v-model:value="form.dateRange" type="daterange" clearable />
|
||||
<div v-else class="flex items-center gap-2 text-sm text-gray-700 font-medium">
|
||||
<div class="i-carbon-calendar text-gray-400" />
|
||||
<span>2026-06-01</span>
|
||||
<span class="text-gray-300">/</span>
|
||||
<span>2026-06-05</span>
|
||||
</div>
|
||||
</NGridItem>
|
||||
|
||||
<!-- 参赛总人数 -->
|
||||
<NGridItem>
|
||||
<div class="mb-2 text-xs text-gray-400 font-medium">
|
||||
参赛队伍总数
|
||||
</div>
|
||||
<NInputNumber v-if="isEdit" v-model:value="form.total" :disabled="true" :show-button="false">
|
||||
<template #suffix>
|
||||
对
|
||||
<div class="flex flex-col gap-6">
|
||||
<NCard :bordered="false" class="rounded-xl shadow-sm">
|
||||
<div class="mb-6 flex items-center justify-between border-l-4 border-blue-600 pl-3">
|
||||
<span class="text-lg text-gray-800 font-bold">基础信息</span>
|
||||
<div class="flex gap-2">
|
||||
<NButton v-if="showEnterEditButton" size="small" type="primary" secondary @click="emit('enterEdit')">
|
||||
编辑
|
||||
</NButton>
|
||||
<template v-else-if="showEditActions">
|
||||
<NButton size="small" secondary @click="emit('cancel')">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton size="small" type="primary" @click="emit('save')">
|
||||
保存
|
||||
</NButton>
|
||||
</template>
|
||||
</NInputNumber>
|
||||
<div v-else class="inline-flex items-center rounded bg-blue-100 px-2.5 py-0.5 text-sm text-blue-700 font-bold">
|
||||
{{ form.total }}对
|
||||
</div>
|
||||
</NGridItem>
|
||||
</div>
|
||||
|
||||
<!-- 分组配额 -->
|
||||
<NGridItem>
|
||||
<!-- 赛事封面 -->
|
||||
<div class="mb-6">
|
||||
<div class="mb-2 text-xs text-gray-400 font-medium">
|
||||
分组配额
|
||||
赛事封面
|
||||
</div>
|
||||
<div v-if="isEdit" class="flex gap-2">
|
||||
<NInputNumber v-model:value="form.teamPerGroup" :disabled="true" size="small" placeholder="单组" :show-button="false" />
|
||||
<NInputNumber v-model:value="form.promoteCount" :disabled="true" size="small" placeholder="晋级" :show-button="false" />
|
||||
<div v-if="isEdit">
|
||||
<OssImageUpload v-model="form.BackgroundImg" :max-size="5120" />
|
||||
<div class="mt-2 text-xs text-gray-400">
|
||||
建议上传 16:9 或 4:3 比例的图片,以获得最佳的全屏显示效果。
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-3 text-sm text-gray-700">
|
||||
<span>单组队伍: <strong>{{ form.teamPerGroup }}队</strong></span>
|
||||
<span class="h-3 w-[1px] bg-gray-300" />
|
||||
<span>队伍数量: <strong>{{ form.promoteCount }}队</strong></span>
|
||||
<div v-else class="w-full overflow-hidden border border-gray-100 rounded-3xl bg-gray-50">
|
||||
<div v-if="form.BackgroundImg" class="h-[200px] w-full flex items-center justify-center">
|
||||
<!-- <img :src="form.BackgroundImg" class="h-full w-full object-cover"> -->
|
||||
<NImage width="100%" height="100%" :src="form.BackgroundImg" object-fit="contain" />
|
||||
</div>
|
||||
<div v-else class="h-[200px] w-full flex items-center justify-center text-sm text-gray-400">
|
||||
暂无封面
|
||||
</div>
|
||||
</div>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
<NGrid :x-gap="24" :cols="4" class="py-2">
|
||||
<!-- 赛事名称 -->
|
||||
<NGridItem>
|
||||
<div class="mb-2 text-xs text-gray-400 font-medium">
|
||||
赛事名称
|
||||
</div>
|
||||
<NInput v-if="isEdit" v-model:value="form.Name" placeholder="输入名称" />
|
||||
<div v-else class="text-base text-gray-800 font-bold">
|
||||
{{ form.Name }}
|
||||
</div>
|
||||
</NGridItem>
|
||||
|
||||
<!-- 赛事副标题 -->
|
||||
<NGridItem>
|
||||
<div class="mb-2 text-xs text-gray-400 font-medium">
|
||||
赛事副标题
|
||||
</div>
|
||||
<NInput v-if="isEdit" v-model:value="form.ActivityTitle" placeholder="输入副标题" />
|
||||
<div v-else class="text-base text-gray-800 font-bold">
|
||||
{{ form.ActivityTitle || '-' }}
|
||||
</div>
|
||||
</NGridItem>
|
||||
|
||||
<!-- 起止时间 -->
|
||||
<NGridItem>
|
||||
<div class="mb-2 text-xs text-gray-400 font-medium">
|
||||
起止时间
|
||||
</div>
|
||||
<NDatePicker
|
||||
v-if="isEdit" v-model:formatted-value="dateRange" value-format="yyyy-MM-dd'T'HH:mm:ss"
|
||||
type="daterange" clearable
|
||||
/>
|
||||
<div v-else class="flex items-center gap-2 text-sm text-gray-700 font-medium">
|
||||
<div class="i-carbon-calendar text-gray-400" />
|
||||
<span>{{ previewDateText.start }}</span>
|
||||
<span class="text-gray-300">/</span>
|
||||
<span>{{ previewDateText.end }}</span>
|
||||
</div>
|
||||
</NGridItem>
|
||||
|
||||
<!-- 参赛总人数 -->
|
||||
<NGridItem>
|
||||
<div class="mb-2 text-xs text-gray-400 font-medium">
|
||||
参赛规模
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-400">总计</span>
|
||||
<NInputNumber v-if="isEdit" v-model:value="form.Teams" :disabled="true" :show-button="false" class="w-20">
|
||||
<template #suffix>
|
||||
对
|
||||
</template>
|
||||
</NInputNumber>
|
||||
<div
|
||||
v-else
|
||||
class="inline-flex items-center rounded bg-blue-100 px-2.5 py-0.5 text-sm text-blue-700 font-bold"
|
||||
>
|
||||
{{ form.Teams }}对
|
||||
</div>
|
||||
|
||||
<span class="ml-2 text-xs text-gray-400">单组</span>
|
||||
<div v-if="isEdit">
|
||||
<NInputNumber
|
||||
v-model:value="form.TeamGroupNumber" :disabled="true" size="small" placeholder="分组数"
|
||||
:show-button="false" class="w-16"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="text-gray-700 font-bold">
|
||||
{{ Math.floor(form.Teams / (form.TeamGroupNumber || 1)) }} 队
|
||||
</div>
|
||||
</div>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
</NCard>
|
||||
|
||||
<NCard :bordered="false" class="rounded-xl shadow-sm">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 border-l-4 border-orange-500 pl-3">
|
||||
<span class="text-lg text-gray-800 font-bold">赛事规则配置</span>
|
||||
</div>
|
||||
<!-- 切换 Tab -->
|
||||
<div class="flex rounded-lg bg-gray-100 p-1">
|
||||
<button
|
||||
class="rounded-md px-4 py-1.5 text-sm font-medium transition-all"
|
||||
:class="activeTab === 'normal' ? 'bg-blue-600 text-white shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||
@click="activeTab = 'normal'"
|
||||
>
|
||||
普通赛事环节
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md px-4 py-1.5 text-sm font-medium transition-all"
|
||||
:class="activeTab === 'extra' ? 'bg-blue-600 text-white shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||
@click="activeTab = 'extra'"
|
||||
>
|
||||
加时挑战环节
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-[200px]">
|
||||
<template v-if="isEdit">
|
||||
<div class="overflow-hidden border border-gray-100 rounded-lg">
|
||||
<Toolbar
|
||||
style="border-bottom: 1px solid #eee" :editor="editorRef" :default-config="toolbarConfig"
|
||||
:mode="mode"
|
||||
/>
|
||||
<div class="relative h-[400px]">
|
||||
<div v-show="activeTab === 'normal'" class="h-full">
|
||||
<Editor
|
||||
v-model="form.ActivityContent" style="height: 100%; overflow-y: hidden;"
|
||||
:default-config="{ ...editorConfig, placeholder: '请输入普通赛事的参与规则及计分标准...' }" :mode="mode"
|
||||
@on-created="handleCreated"
|
||||
/>
|
||||
</div>
|
||||
<div v-show="activeTab === 'extra'" class="h-full">
|
||||
<Editor
|
||||
v-model="form.ExtraTimeContent" style="height: 100%; overflow-y: hidden;"
|
||||
:default-config="{ ...editorConfig, placeholder: '请输入加时赛的参与规则及计分标准...' }" :mode="mode"
|
||||
@on-created="handleCreated"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
class="prose max-w-none rounded-lg bg-gray-50/50 p-4"
|
||||
v-html="activeContent || '<div class=\'text-gray-400 text-center py-8\'>暂无规则配置</div>'"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</NCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.w-e-text-container) {
|
||||
background-color: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,230 +1,482 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NCard, NForm, NFormItem, NGrid, NGridItem, NInputNumber, NModal, NSelect } from 'naive-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { NButton, NCard, NForm, NFormItem, NInput, 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'
|
||||
import { useDict } from '@/hooks/business/useDict'
|
||||
import { fetchGetQuestionListAll } from '@/service/api/question'
|
||||
import { fetchTemplateList } from '@/service/api/template'
|
||||
import { useQuestionConfig } from '../../competition-add/modules/useQuestionConfig'
|
||||
|
||||
defineProps<{ isEdit: boolean }>()
|
||||
const props = defineProps<{
|
||||
isEdit: boolean
|
||||
questions: Array<Api.Competition.QuestionListRecord>
|
||||
publishStatus: number
|
||||
}>()
|
||||
|
||||
const scoreOptions = [
|
||||
{ label: '1分', value: '1' },
|
||||
{ label: '2分', value: '2' },
|
||||
{ label: '3分', value: '3' },
|
||||
{ label: '5分', value: '5' },
|
||||
{ label: '10分', value: '10' },
|
||||
]
|
||||
const emit = defineEmits(['enterEdit', 'save', 'cancel'])
|
||||
|
||||
interface Question {
|
||||
id: string
|
||||
label: string
|
||||
type: string
|
||||
score: string
|
||||
time: number | null
|
||||
}
|
||||
|
||||
// 初始化常规赛题目数据
|
||||
const regularQuestions = ref<Question[]>(Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `reg-${i}`,
|
||||
label: `第${i + 1}题`,
|
||||
type: 'hanzi',
|
||||
score: '1',
|
||||
time: 30,
|
||||
})))
|
||||
|
||||
// 初始化PK赛题目数据
|
||||
const pkQuestions = ref<Question[]>(Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `pk-${i}`,
|
||||
label: `PK${i + 1}`,
|
||||
type: 'hanzi',
|
||||
score: '1',
|
||||
time: 30,
|
||||
})))
|
||||
|
||||
const regularTotalScore = computed(() => regularQuestions.value.reduce((acc, cur) => acc + Number(cur.score || 0), 0))
|
||||
const pkTotalScore = computed(() => pkQuestions.value.reduce((acc, cur) => acc + Number(cur.score || 0), 0))
|
||||
|
||||
// 快速配置相关逻辑
|
||||
const showQuickConfigModal = ref(false)
|
||||
const quickConfigForm = ref({
|
||||
score: '1',
|
||||
time: 30,
|
||||
// 本地数据模型,用于适配 useQuestionConfig
|
||||
const modelValue = ref<{
|
||||
rounds: Array<{
|
||||
id: string
|
||||
name: string
|
||||
roundType: string
|
||||
questions: Array<{
|
||||
id: string
|
||||
QuestionID: number
|
||||
QuestionTime: number
|
||||
ActitvityQuestionName: string
|
||||
QuestionRule: number
|
||||
Point: number
|
||||
UIType?: Api.Competition.QuestionTemplateId
|
||||
TemplateID?: number
|
||||
// 额外字段用于回显时保留原始信息
|
||||
ID?: number
|
||||
}>
|
||||
}>
|
||||
}>({
|
||||
rounds: [],
|
||||
})
|
||||
|
||||
function handleBatchReset() {
|
||||
regularQuestions.value.forEach((q) => {
|
||||
q.score = '1'
|
||||
q.time = 30
|
||||
})
|
||||
pkQuestions.value.forEach((q) => {
|
||||
q.score = '1'
|
||||
q.time = 30
|
||||
})
|
||||
const { options: roundTypeOptions } = useDict('round_type')
|
||||
const { options: scoreTypeOptions } = useDict('score_type')
|
||||
const { options: timeOptions } = useDict('time_out')
|
||||
const { options: uiTypeOptions } = useDict('ui_type', 'string')
|
||||
|
||||
// 我们构造一个包含 modelValue 属性的对象,类似于 props
|
||||
const hookProps = reactive({
|
||||
modelValue,
|
||||
})
|
||||
|
||||
const {
|
||||
getRoundTime,
|
||||
totalStats,
|
||||
addRound,
|
||||
removeRound,
|
||||
addQuestion,
|
||||
removeQuestion,
|
||||
handleDragEnd,
|
||||
handleTypeChange,
|
||||
validate,
|
||||
reset,
|
||||
} = useQuestionConfig(hookProps)
|
||||
|
||||
const questionOptions = ref<SelectOption[]>([])
|
||||
const templateIdOptions = ref<SelectOption[]>([])
|
||||
|
||||
// 模态框控制
|
||||
const showAddModal = ref(false)
|
||||
const newRoundForm = ref({
|
||||
count: 10,
|
||||
roundType: roundTypeOptions?.value?.[0]?.value || 0,
|
||||
})
|
||||
|
||||
function handleOpenAddModal() {
|
||||
newRoundForm.value = {
|
||||
count: 10,
|
||||
roundType: roundTypeOptions?.value?.[0]?.value || 0,
|
||||
}
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
function handleQuickConfig() {
|
||||
showQuickConfigModal.value = true
|
||||
function handleConfirmAdd() {
|
||||
addRound(undefined, newRoundForm.value.count, newRoundForm.value.roundType as Api.Competition.CompetitionRoundType)
|
||||
showAddModal.value = false
|
||||
}
|
||||
|
||||
function applyQuickConfig() {
|
||||
const { score, time } = quickConfigForm.value
|
||||
regularQuestions.value.forEach((q) => {
|
||||
q.score = score
|
||||
q.time = time
|
||||
// 数据转换逻辑:Flat List -> Rounds
|
||||
watch(() => props.questions, (val) => {
|
||||
if (!val || val.length === 0) {
|
||||
modelValue.value.rounds = []
|
||||
return
|
||||
}
|
||||
|
||||
const roundsMap = new Map<string, typeof modelValue.value.rounds[0]>()
|
||||
|
||||
val.forEach((q) => {
|
||||
const roundTypeStr = String(q.RoundType)
|
||||
|
||||
if (!roundsMap.has(roundTypeStr)) {
|
||||
roundsMap.set(roundTypeStr, {
|
||||
id: `round_restored_${roundTypeStr}`,
|
||||
name: `${roundTypeOptions.value?.find((opt: any) => opt.value === roundTypeStr)?.label || '未知类型'}`,
|
||||
roundType: roundTypeStr,
|
||||
questions: [],
|
||||
})
|
||||
}
|
||||
|
||||
const round = roundsMap.get(roundTypeStr)
|
||||
if (round) {
|
||||
round.questions.push({
|
||||
id: `q_restored_${q.ID}`,
|
||||
ID: q.ID,
|
||||
QuestionID: q.QuestionID,
|
||||
ActitvityQuestionName: q.ActitvityQuestionName || `第${round.questions.length + 1}题`,
|
||||
QuestionTime: q.QuestionTime,
|
||||
Point: q.Point,
|
||||
QuestionRule: q.QuestionRule,
|
||||
UIType: q.UIType,
|
||||
TemplateID: q.TemplateID,
|
||||
})
|
||||
}
|
||||
})
|
||||
pkQuestions.value.forEach((q) => {
|
||||
q.score = score
|
||||
q.time = time
|
||||
})
|
||||
showQuickConfigModal.value = false
|
||||
|
||||
modelValue.value.rounds = Array.from(roundsMap.values())
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
const isDataLoaded = ref(false)
|
||||
|
||||
/** 获取所有题目 */
|
||||
async function getAllQuestions() {
|
||||
const { data: res, error } = await fetchGetQuestionListAll()
|
||||
if (error) {
|
||||
window.$message?.error(error.message)
|
||||
return
|
||||
}
|
||||
const { data } = res || []
|
||||
questionOptions.value = data?.map((item: any) => ({
|
||||
label: `${item.Name}-${item.QuestionContent}`,
|
||||
value: item.Id, // Ensure value is number if QuestionID is number
|
||||
})) || []
|
||||
}
|
||||
|
||||
/** 获取所有模板 */
|
||||
async function getAllTemplates() {
|
||||
const { data: res, error } = await fetchTemplateList()
|
||||
|
||||
if (error) {
|
||||
window.$message?.error(error.message)
|
||||
return
|
||||
}
|
||||
const { data } = res || []
|
||||
templateIdOptions.value = Array.isArray(data)
|
||||
? data.map((item: any) => ({
|
||||
label: item.Name,
|
||||
value: item.ID || item.id,
|
||||
})) || []
|
||||
: []
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!isDataLoaded.value) {
|
||||
getAllQuestions()
|
||||
getAllTemplates()
|
||||
isDataLoaded.value = true
|
||||
}
|
||||
})
|
||||
|
||||
function getRoundTypeName(roundType: string) {
|
||||
const option = roundTypeOptions.value?.find((item: any) => item.value === roundType)
|
||||
return option?.label || '未知类型'
|
||||
}
|
||||
|
||||
/** 是否显示编辑按钮 */
|
||||
const showEnterEditButton = computed(() => {
|
||||
return !props.isEdit && [PublishStatus.Unpublished, PublishStatus.Published].includes(props.publishStatus)
|
||||
})
|
||||
|
||||
/** 是否显示保存/取消按钮 */
|
||||
const showEditActions = computed(() => props.isEdit)
|
||||
|
||||
defineExpose({ validate, reset, modelValue })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard :bordered="false" class="rounded-xl shadow-sm">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 border-l-4 border-purple-500 pl-3">
|
||||
<span class="text-lg text-gray-800 font-bold">题包配置</span>
|
||||
</div>
|
||||
<!-- 编辑模式下显示批量操作按钮 -->
|
||||
<div v-if="isEdit" class="flex gap-3">
|
||||
<NButton size="small" secondary @click="handleBatchReset">
|
||||
批量重置
|
||||
</NButton>
|
||||
<NButton size="small" type="primary" secondary @click="handleQuickConfig">
|
||||
快速配置
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-6">
|
||||
<NCard :bordered="false" class="rounded-xl shadow-sm">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-3 border-l-4 border-blue-600 pl-3">
|
||||
<span class="text-lg text-gray-800 font-bold">赛程题包配置</span>
|
||||
</div>
|
||||
<span class="pl-4 text-xs text-gray-400">您可以为比赛添加多个阶段的题包,并自定义题目、分值与限时。</span>
|
||||
</div>
|
||||
|
||||
<NGrid :x-gap="24" :cols="2">
|
||||
<!-- 常规赛 -->
|
||||
<NGridItem>
|
||||
<div class="flex items-center justify-between border-b border-blue-100 rounded-t-lg bg-blue-50/50 px-4 py-3">
|
||||
<div class="flex items-center gap-2 text-gray-800 font-bold">
|
||||
<div class="i-carbon-document-tasks text-blue-500" />
|
||||
常规赛题包
|
||||
</div>
|
||||
<div class="text-xs text-[#8DA5C3] font-medium">
|
||||
共 {{ regularQuestions.length }} 题 / 总计 {{ regularTotalScore }} 分
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 border border-t-0 border-blue-100 rounded-b-lg bg-[#F5F9FF] p-4">
|
||||
<div v-for="item in regularQuestions" :key="item.id" class="flex items-center gap-2">
|
||||
<div class="w-16 flex-shrink-0 text-xs text-[#8DA5C3] font-bold">
|
||||
{{ item.label }}:
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
v-if="totalStats.count > 0"
|
||||
class="flex items-center gap-3 border border-gray-100 rounded-lg bg-gray-50 px-3 py-1 text-xs font-bold"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-gray-400">总题数</span>
|
||||
<span class="text-sm text-gray-700">{{ totalStats.count }}</span>
|
||||
</div>
|
||||
<NSelect
|
||||
v-model:value="item.type"
|
||||
size="small"
|
||||
placeholder="题型"
|
||||
:options="[{ label: '汉字书写', value: 'hanzi' }]"
|
||||
:disabled="!isEdit"
|
||||
class="min-w-[120px] flex-1"
|
||||
/>
|
||||
<div class="w-20 flex-shrink-0">
|
||||
<NSelect
|
||||
v-model:value="item.score"
|
||||
size="small"
|
||||
:options="scoreOptions"
|
||||
:disabled="!isEdit"
|
||||
placeholder="分值"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-20 flex-shrink-0">
|
||||
<NInputNumber
|
||||
v-model:value="item.time"
|
||||
size="small"
|
||||
placeholder="秒"
|
||||
:show-button="false"
|
||||
:disabled="!isEdit"
|
||||
>
|
||||
<template #suffix>
|
||||
s
|
||||
</template>
|
||||
</NInputNumber>
|
||||
<div class="h-3 w-[1px] bg-gray-200" />
|
||||
<div class="h-3 w-[1px] bg-gray-200" />
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-gray-400">总时长</span>
|
||||
<span class="text-sm text-blue-500">{{ totalStats.time }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NGridItem>
|
||||
|
||||
<!-- PK赛 -->
|
||||
<NGridItem>
|
||||
<div class="flex items-center justify-between border-b border-orange-100 rounded-t-lg bg-orange-50/50 px-4 py-3">
|
||||
<div class="flex items-center gap-2 text-[#5E3218] font-bold">
|
||||
<div class="i-carbon-timer text-[#D96B23]" />
|
||||
加时赛题包
|
||||
</div>
|
||||
<div class="text-xs text-[#D96B23]/60 font-medium">
|
||||
共 {{ pkQuestions.length }} 题 / 总计 {{ pkTotalScore }} 分
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 border border-t-0 border-orange-100 rounded-b-lg bg-[#FFF9F5] p-4">
|
||||
<div v-for="item in pkQuestions" :key="item.id" class="flex items-center gap-2">
|
||||
<div class="w-16 flex-shrink-0 text-xs text-[#FF8C38] font-bold">
|
||||
{{ item.label }}:
|
||||
</div>
|
||||
<NSelect
|
||||
v-model:value="item.type"
|
||||
size="small"
|
||||
placeholder="题型"
|
||||
:options="[{ label: '汉字书写', value: 'hanzi' }]"
|
||||
:disabled="!isEdit"
|
||||
class="min-w-[120px] flex-1"
|
||||
/>
|
||||
<div class="w-20 flex-shrink-0">
|
||||
<NSelect
|
||||
v-model:value="item.score"
|
||||
size="small"
|
||||
:options="scoreOptions"
|
||||
:disabled="!isEdit"
|
||||
placeholder="分值"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-20 flex-shrink-0">
|
||||
<NInputNumber
|
||||
v-model:value="item.time"
|
||||
size="small"
|
||||
placeholder="秒"
|
||||
:show-button="false"
|
||||
:disabled="!isEdit"
|
||||
>
|
||||
<template #suffix>
|
||||
s
|
||||
</template>
|
||||
</NInputNumber>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
|
||||
<!-- 快速配置弹窗 -->
|
||||
<NModal
|
||||
v-model:show="showQuickConfigModal"
|
||||
preset="card"
|
||||
title="快速配置"
|
||||
class="w-[400px]"
|
||||
>
|
||||
<NForm :model="quickConfigForm" label-placement="left" label-width="80">
|
||||
<NFormItem label="统一分值">
|
||||
<NSelect v-model:value="quickConfigForm.score" :options="scoreOptions" />
|
||||
</NFormItem>
|
||||
<NFormItem label="统一时间">
|
||||
<NInputNumber v-model:value="quickConfigForm.time" :show-button="false">
|
||||
<template #suffix>
|
||||
秒
|
||||
<div class="flex gap-2">
|
||||
<NButton v-if="showEnterEditButton" size="small" type="primary" secondary @click="emit('enterEdit')">
|
||||
编辑
|
||||
</NButton>
|
||||
<template v-else-if="showEditActions">
|
||||
<NButton size="small" secondary @click="emit('cancel')">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton size="small" type="primary" @click="emit('save')">
|
||||
保存
|
||||
</NButton>
|
||||
</template>
|
||||
</NInputNumber>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<NButton @click="showQuickConfigModal = false">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton type="primary" @click="applyQuickConfig">
|
||||
应用
|
||||
</div>
|
||||
|
||||
<NButton
|
||||
v-if="isEdit"
|
||||
type="primary" secondary size="medium" class="!font-bold"
|
||||
@click="handleOpenAddModal"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-ic-round-plus />
|
||||
</template>
|
||||
新增赛程题包
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="!modelValue.rounds?.length"
|
||||
class="flex flex-col flex-1 items-center justify-center border-2 border-gray-100 rounded-2xl border-dashed bg-gray-50/30 py-12"
|
||||
>
|
||||
<div class="h-34 w-34 flex items-center justify-center rounded-3xl bg-gray-100 text-gray-300">
|
||||
<icon-ic-outline-folder-off class="text-5xl" />
|
||||
</div>
|
||||
<div class="mt-6 text-lg text-gray-400 font-bold">
|
||||
暂无配置题包
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-gray-500">
|
||||
当前暂无配置
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表内容 -->
|
||||
<div v-else class="flex flex-col gap-6 pb-4">
|
||||
<div
|
||||
v-for="(round, roundIndex) in modelValue.rounds" :key="round.id"
|
||||
class="group relative overflow-hidden border border-gray-100 rounded-2xl bg-white p-1 shadow-sm transition-all hover:shadow-md"
|
||||
>
|
||||
<!-- 侧边装饰条 -->
|
||||
<div class="absolute bottom-0 left-0 top-0 w-1.5 bg-blue-500" />
|
||||
|
||||
<div class="p-6 pl-8">
|
||||
<!-- 头部信息 -->
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<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) }}
|
||||
</NTag>
|
||||
<div class="flex items-center gap-3">
|
||||
<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"
|
||||
>
|
||||
<icon-ic-outline-format-list-numbered class="text-sm" />
|
||||
{{ round.questions.length }} 题
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-1 border border-blue-100 rounded-full bg-blue-50 px-3 py-1 text-xs text-blue-500 font-bold"
|
||||
>
|
||||
<icon-ic-outline-access-time class="text-sm" />
|
||||
{{ getRoundTime(roundIndex) }} 秒
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isEdit" class="flex items-center gap-3">
|
||||
<NButton
|
||||
secondary circle class="!text-gray-400 hover:!bg-blue-50 hover:!text-blue-500"
|
||||
@click="addQuestion(roundIndex)"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="!text-xl" />
|
||||
</template>
|
||||
</NButton>
|
||||
<NButton
|
||||
secondary circle class="!text-gray-400 hover:!bg-red-50 hover:!text-red-500"
|
||||
@click="removeRound(roundIndex)"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-ic-outline-delete class="!text-xl" />
|
||||
</template>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 题目列表 -->
|
||||
<VueDraggable
|
||||
v-model="round.questions" :animation="300" handle=".drag-handle" class="flex flex-col gap-3"
|
||||
ghost-class="ghost"
|
||||
:disabled="!isEdit"
|
||||
@end="(evt) => handleDragEnd(roundIndex, evt)"
|
||||
>
|
||||
<div
|
||||
v-for="(item, qIdx) in round.questions" :key="item.id"
|
||||
class="group/item flex flex-col gap-3 border border-gray-100 rounded-xl bg-white p-3 transition-all hover:border-blue-200 hover:shadow-sm"
|
||||
>
|
||||
<!-- 第一行:序号、标题 -->
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex flex-1 items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
v-if="isEdit"
|
||||
class="drag-handle flex cursor-grab items-center justify-center rounded p-1 text-gray-400 transition-colors active:cursor-grabbing hover:bg-gray-100 hover:text-blue-500"
|
||||
>
|
||||
<icon-mdi-drag class="text-xl" />
|
||||
</div>
|
||||
<span
|
||||
class="w-6 flex-shrink-0 text-center text-sm text-gray-300 font-bold transition-colors group-hover/item:text-blue-500"
|
||||
>
|
||||
<!-- {{ String(qIdx + 1).padStart(2, '0') }} -->
|
||||
<NTag type="primary" class="!text-blue-500">{{ item.QuestionID }}</NTag>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="flex flex-1 items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
|
||||
<icon-ic-outline-title class="text-gray-400" />
|
||||
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">标题:</span>
|
||||
<NInput
|
||||
v-model:value="item.ActitvityQuestionName" class="flex-1 !bg-transparent" size="small" :bordered="false"
|
||||
placeholder="请输入题目标题"
|
||||
:disabled="!isEdit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 删除按钮 -->
|
||||
<NButton
|
||||
v-if="isEdit"
|
||||
text class="transition-opacity !text-gray-300 hover:!text-red-500"
|
||||
@click="removeQuestion(roundIndex, qIdx)"
|
||||
>
|
||||
<icon-ic-outline-delete class="!text-xl" />
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:配置项 -->
|
||||
<div class="grid grid-cols-12 gap-3">
|
||||
<!-- 题型 -->
|
||||
<div class="col-span-4">
|
||||
<NSelect
|
||||
v-model:value="item.QuestionID" :options="questionOptions" placeholder="请选择题目类型" size="small"
|
||||
class="font-medium"
|
||||
:disabled="!isEdit"
|
||||
@update:value="() => handleTypeChange(roundIndex)"
|
||||
/>
|
||||
</div>
|
||||
<!-- UItype -->
|
||||
<div class="col-span-4">
|
||||
<NSelect
|
||||
v-model:value="item.UIType" :options="uiTypeOptions" placeholder="请选择UI类型" size="small"
|
||||
class="font-medium"
|
||||
:disabled="!isEdit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- templateId -->
|
||||
<div class="col-span-4">
|
||||
<NSelect
|
||||
v-model:value="item.TemplateID" :options="templateIdOptions" placeholder="请选择模板" size="small"
|
||||
class="font-medium"
|
||||
:disabled="!isEdit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 时间 -->
|
||||
<div class="col-span-4 flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
|
||||
<icon-ic-outline-timer class="text-gray-400" />
|
||||
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">答题时间:</span>
|
||||
<NSelect
|
||||
v-model:value="item.QuestionTime" :options="timeOptions" class="flex-1 !bg-transparent" size="small"
|
||||
:bordered="false"
|
||||
:disabled="!isEdit"
|
||||
/>
|
||||
<span class="text-xs text-gray-400 font-bold">S</span>
|
||||
</div>
|
||||
|
||||
<!-- 分数规则 -->
|
||||
<div class="col-span-4 flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
|
||||
<icon-streamline-sharp:type-area-remix class="text-gray-400" />
|
||||
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">分数规则:</span>
|
||||
<NSelect
|
||||
v-model:value="item.QuestionRule" :options="scoreTypeOptions as unknown as SelectOption[]"
|
||||
class="flex-1 !bg-transparent" size="small" :bordered="false"
|
||||
:disabled="!isEdit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 分数 -->
|
||||
<div class="col-span-4 flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
|
||||
<icon-ic-round-star-border class="text-gray-400" />
|
||||
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">分数:</span>
|
||||
<NInputNumber
|
||||
v-model:value="item.Point" :min="0" :show-button="false" class="flex-1 !bg-transparent"
|
||||
size="small" :bordered="false" placeholder="0"
|
||||
:disabled="!isEdit"
|
||||
/>
|
||||
<span class="text-xs text-gray-400 font-bold">分</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VueDraggable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
<!-- 新增题包弹窗 -->
|
||||
<NModal v-model:show="showAddModal" transform-origin="center">
|
||||
<div class="w-[480px] rounded-2xl bg-white p-8 shadow-2xl">
|
||||
<div class="mb-8 flex flex-col items-center gap-4 text-center">
|
||||
<div class="h-14 w-14 flex items-center justify-center rounded-2xl bg-blue-50 text-blue-500">
|
||||
<icon-ic-round-library-add class="text-3xl" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl text-gray-800 font-bold">
|
||||
新建环节题包
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-500">
|
||||
请输入环节名称与题目预设数量
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NForm size="large">
|
||||
<NFormItem label="环节/题包类型">
|
||||
<NSelect
|
||||
v-model:value="newRoundForm.roundType" :options="roundTypeOptions as unknown as SelectOption[]"
|
||||
placeholder="请选择环节类型..." :bordered="true" class="font-medium"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="初始化题目数量">
|
||||
<NInputNumber v-model:value="newRoundForm.count" :min="1" :max="50" class="w-full" />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
|
||||
<NButton
|
||||
type="primary" block size="large" class="mt-4 !h-12 !rounded-xl !text-lg !font-bold"
|
||||
@click="handleConfirmAdd"
|
||||
>
|
||||
生成题包卡片
|
||||
</NButton>
|
||||
</div>
|
||||
</NModal>
|
||||
</NCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ghost {
|
||||
@apply opacity-50 bg-blue-50 border-2 border-dashed border-blue-300;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,13 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton } from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { fetchGetGroupList } from '@/service/api/competition'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
activityId?: string
|
||||
disabled?: boolean
|
||||
}>(), {
|
||||
activityId: '0',
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const activeId = ref()
|
||||
|
||||
const activeId = ref(1)
|
||||
const groups = ref(Array.from({ length: 10 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
code: String(i + 1).padStart(2, '0'),
|
||||
name: `第${['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][i]}组`,
|
||||
})))
|
||||
|
||||
/** 获取分组列表 */
|
||||
async function getGroupList() {
|
||||
const { data, error } = await fetchGetGroupList(props.activityId)
|
||||
if (data && !error) {
|
||||
const dataList = data?.data as any[] || []
|
||||
const groupList = dataList.map((item: any, index: number) => ({
|
||||
id: item.Id || index + 1,
|
||||
code: item.number || '',
|
||||
name: item.Name || '',
|
||||
}))
|
||||
groups.value = groupList
|
||||
// 设置默认选中第一个分组
|
||||
activeId.value = groupList[0]?.id || ''
|
||||
handleClick(activeId.value)
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击分组 */
|
||||
function handleClick(id: number) {
|
||||
if (props.disabled) {
|
||||
window.$message?.warning('当前正在编辑中,请先保存或取消')
|
||||
return
|
||||
}
|
||||
activeId.value = id
|
||||
emit('change', id)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getGroupList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -29,24 +72,20 @@ const groups = ref(Array.from({ length: 10 }, (_, i) => ({
|
||||
<!-- 列表区域 -->
|
||||
<div class="max-h-[calc(100vh-120px)] overflow-y-auto p-2">
|
||||
<div
|
||||
v-for="item in groups"
|
||||
:key="item.id"
|
||||
v-for="item in groups" :key="item.id"
|
||||
class="group mb-1 flex cursor-pointer items-center gap-3 rounded-lg px-4 py-3 transition-colors"
|
||||
:class="activeId === item.id ? 'bg-blue-50 text-blue-600' : 'hover:bg-gray-50 text-gray-600'"
|
||||
@click="activeId = item.id"
|
||||
:class="[
|
||||
activeId === item.id ? 'bg-blue-50 text-blue-600' : 'hover:bg-gray-50 text-gray-600',
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : '',
|
||||
]"
|
||||
@click="handleClick(item.id)"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-mono"
|
||||
:class="activeId === item.id ? 'text-blue-400' : 'text-gray-300'"
|
||||
>
|
||||
<span class="text-xs font-mono" :class="activeId === item.id ? 'text-blue-400' : 'text-gray-300'">
|
||||
#{{ item.code }}
|
||||
</span>
|
||||
<span class="font-medium">{{ item.name }}</span>
|
||||
|
||||
<div
|
||||
v-if="activeId === item.id"
|
||||
class="ml-auto h-4 w-1 rounded-full bg-blue-600"
|
||||
/>
|
||||
<div v-if="activeId === item.id" class="ml-auto h-4 w-1 rounded-full bg-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,38 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { NCard, NGrid, NGridItem, NInput } from 'naive-ui'
|
||||
import { NButton, NCard, NGrid, NGridItem, NInput } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import OssImageUpload from '@/components/common/oss-image-upload/index.vue'
|
||||
import { PublishStatus } from '@/enum/business'
|
||||
|
||||
defineProps<{ isEdit: boolean }>()
|
||||
const props = defineProps<{ isEdit: boolean, teams: Array<Api.Competition.TeamListRecord>, publishStatus: number }>()
|
||||
const emit = defineEmits(['enterEdit', 'save', 'cancel'])
|
||||
|
||||
const localTeams = ref<Array<Api.Competition.TeamListRecord>>([])
|
||||
|
||||
/** 同步父组件队伍列表到本地数据 */
|
||||
function syncTeams(list: Array<Api.Competition.TeamListRecord>) {
|
||||
localTeams.value = Array.isArray(list) ? list : []
|
||||
}
|
||||
|
||||
watch(() => props.teams, val => syncTeams(val || []), { immediate: true, deep: true })
|
||||
|
||||
/** 是否显示编辑按钮 */
|
||||
const showEnterEditButton = computed(() => {
|
||||
return !props.isEdit && [PublishStatus.Unpublished, PublishStatus.Published].includes(props.publishStatus)
|
||||
})
|
||||
|
||||
/** 是否显示保存/取消按钮 */
|
||||
const showEditActions = computed(() => props.isEdit)
|
||||
|
||||
defineExpose({ localTeams })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard :bordered="false" class="rounded-xl shadow-sm">
|
||||
<div class="mb-5 flex items-center gap-3 border-l-4 border-green-500 pl-3">
|
||||
<div class="mb-5 flex items-center justify-between border-l-4 border-green-500 pl-3">
|
||||
<span class="text-lg text-gray-800 font-bold">参与队伍</span>
|
||||
<div class="flex gap-2">
|
||||
<NButton v-if="showEnterEditButton" size="small" type="primary" secondary @click="emit('enterEdit')">
|
||||
编辑
|
||||
</NButton>
|
||||
<template v-else-if="showEditActions">
|
||||
<NButton size="small" secondary @click="emit('cancel')">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton size="small" type="primary" @click="emit('save')">
|
||||
保存
|
||||
</NButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NGrid :x-gap="16" :y-gap="16" cols="1 m:2 l:4" responsive="screen">
|
||||
<NGridItem v-for="i in 4" :key="i">
|
||||
<NGridItem v-for="(item, i) in localTeams" :key="i">
|
||||
<div
|
||||
class="relative border rounded-lg bg-gray-50 p-4 transition-all"
|
||||
:class="isEdit ? 'border-blue-200 bg-white' : 'border-transparent hover:shadow-md'"
|
||||
>
|
||||
<div class="mb-3 inline-block rounded bg-gray-200 px-2 py-0.5 text-xs text-gray-600 font-bold">
|
||||
TEAM 0{{ i }}
|
||||
TEAM {{ String(i + 1).padStart(2, '0') }}
|
||||
</div>
|
||||
|
||||
<template v-if="isEdit">
|
||||
<NInput size="small" placeholder="输入队伍名称" class="mb-2 font-bold" default-value="重庆树人小学队伍" />
|
||||
<NInput size="tiny" placeholder="输入编号" class="font-mono" default-value="BPB-53H-1EN-RL" />
|
||||
</template>
|
||||
<div class="flex items-center gap-3">
|
||||
<template v-if="isEdit">
|
||||
<OssImageUpload
|
||||
v-model="item.HeadImg"
|
||||
variant="mini"
|
||||
class="h-10 w-10 shrink-0"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<NInput v-model:value="item.Name" size="small" placeholder="输入队伍名称" class="mb-2 font-bold" />
|
||||
<NInput v-model:value="item.PenSerial" size="tiny" disabled placeholder="输入编号" class="font-mono" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<h4 class="m-0 mb-1 text-base text-gray-800 font-bold">
|
||||
重庆树人小学队伍
|
||||
</h4>
|
||||
<p class="m-0 text-xs text-gray-400 font-mono">
|
||||
BPB-53H-1EN-RL
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="item.HeadImg" class="h-10 w-10 shrink-0 overflow-hidden rounded-lg bg-gray-200">
|
||||
<img :src="item.HeadImg" class="h-full w-full object-cover" alt="team-logo">
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h4 class="m-0 mb-1 truncate text-base text-gray-800 font-bold">
|
||||
{{ item.Name || '未命名队伍' }}
|
||||
</h4>
|
||||
<p class="m-0 truncate text-xs text-gray-400 font-mono">
|
||||
{{ item.PenSerial || '—' }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
|
||||
@ -1,15 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
// import type { CompetitionItem } from './modules/data'
|
||||
import { NButton, NGi, NGrid, NSpace } from 'naive-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { fetchDeleteActivity, fetchGetActivityList } from '@/service/api/competition'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import CompetitionAddModal from '../competition-add/index.vue'
|
||||
import CompetitionCard from './modules/competition-card.vue'
|
||||
import { type CompetitionItem, competitionList } from './modules/data'
|
||||
|
||||
export interface CompetitionItem {
|
||||
Id: number
|
||||
Name: string
|
||||
StartTime: string
|
||||
EndTime: string
|
||||
TeamGroupNumber: number
|
||||
PublishStatus: number
|
||||
}
|
||||
|
||||
const appStore = useAppStore()
|
||||
const gap = computed(() => (appStore.isMobile ? 12 : 26))
|
||||
|
||||
const competitionListRef = ref(competitionList)
|
||||
const competitionListRef = ref<CompetitionItem[]>([])
|
||||
|
||||
const showAddModal = ref(false)
|
||||
|
||||
@ -19,39 +29,36 @@ function handleAdd() {
|
||||
|
||||
function handleCopy(item: CompetitionItem) {
|
||||
// TODO: 实现复制逻辑
|
||||
window.$message?.success(`已复制活动:${item.title}`)
|
||||
window.$message?.success(`已复制活动:${item.Name}`)
|
||||
}
|
||||
|
||||
function handleDelete(item: CompetitionItem) {
|
||||
const d = window.$dialog?.warning({
|
||||
title: '确认删除',
|
||||
content: `确定删除活动:${item.title}吗?`,
|
||||
content: `确定删除活动:${item.Name}吗?`,
|
||||
positiveText: '确认',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
if (d) {
|
||||
d.loading = true
|
||||
}
|
||||
// 模拟接口请求延迟 2秒
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
try {
|
||||
// 模拟 50% 概率失败
|
||||
if (Math.random() < 0.5) {
|
||||
throw new Error('模拟服务器异常,删除失败')
|
||||
const { error } = await fetchDeleteActivity(item.Id)
|
||||
if (error) {
|
||||
window.$message?.error(error.message || '删除失败')
|
||||
return false
|
||||
}
|
||||
|
||||
// 成功逻辑
|
||||
const index = competitionListRef.value.findIndex(i => i.id === item.id)
|
||||
if (index > -1) {
|
||||
competitionListRef.value.splice(index, 1)
|
||||
window.$message?.success(`已删除活动:${item.title}`)
|
||||
}
|
||||
return true // 返回 true 关闭弹窗
|
||||
window.$message?.success(`已删除活动:${item.Name}`)
|
||||
// 刷新列表
|
||||
fetchActivityList()
|
||||
return true
|
||||
}
|
||||
catch (error: any) {
|
||||
window.$message?.error(error.message)
|
||||
return false // 返回 false 阻止关闭弹窗
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
if (d) {
|
||||
@ -61,6 +68,21 @@ function handleDelete(item: CompetitionItem) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取活动列表 */
|
||||
async function fetchActivityList() {
|
||||
try {
|
||||
const { response, error } = await fetchGetActivityList()
|
||||
if (!error) {
|
||||
competitionListRef.value = response?.data?.data || []
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => fetchActivityList())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -87,12 +109,12 @@ function handleDelete(item: CompetitionItem) {
|
||||
<!-- 卡片列表区域 -->
|
||||
<!-- 响应式布局:手机1列,平板2列,中屏3列,大屏4列 -->
|
||||
<NGrid :x-gap="gap" :y-gap="gap" responsive="screen" item-responsive>
|
||||
<NGi v-for="item in competitionListRef" :key="item.id" span="24 s:12 m:8 l:6">
|
||||
<NGi v-for="item in competitionListRef" :key="item.Id" span="24 s:12 m:8 l:6">
|
||||
<CompetitionCard :item="item" @copy="handleCopy" @delete="handleDelete" />
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
<CompetitionAddModal v-model:show="showAddModal" />
|
||||
<CompetitionAddModal v-model:show="showAddModal" @success="fetchActivityList" />
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { CompetitionItem } from './data'
|
||||
import type { CompetitionItem } from '../index.vue'
|
||||
import { NButton, NCard, NTag, NTooltip } from 'naive-ui'
|
||||
import { computed } from 'vue'
|
||||
import { PublishStatus } from '@/enum/business'
|
||||
import { useDict } from '@/hooks/business/useDict'
|
||||
import { useRouterPush } from '@/hooks/common/router'
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@ -11,6 +13,8 @@ const emit = defineEmits<{
|
||||
(e: 'delete', item: CompetitionItem): void
|
||||
}>()
|
||||
|
||||
const { options: publishStatusOptions } = useDict('publish_status')
|
||||
|
||||
const { routerPushByKey } = useRouterPush()
|
||||
|
||||
interface Props {
|
||||
@ -19,20 +23,28 @@ interface Props {
|
||||
|
||||
// 状态配置:颜色和文本
|
||||
const statusConfig = computed(() => {
|
||||
if (props.item.status === 'ongoing') {
|
||||
return { type: 'success', text: '进行中', bgClass: 'bg-green-100 text-green-600' }
|
||||
const status = props.item.PublishStatus
|
||||
const label = publishStatusOptions.value?.find((item: any) => item.value === status)?.label || ''
|
||||
|
||||
switch (status) {
|
||||
case PublishStatus.Unpublished:
|
||||
return { type: 'default', text: label, bgClass: 'bg-unpublished-100 text-unpublished-600' }
|
||||
case PublishStatus.Published:
|
||||
return { type: 'primary', text: label, bgClass: 'bg-published-100 text-published-600' }
|
||||
case PublishStatus.Processing:
|
||||
return { type: 'success', text: label, bgClass: 'bg-processing-100 text-processing-600' }
|
||||
case PublishStatus.Finished:
|
||||
return { type: 'error', text: label, bgClass: 'bg-finished-100 text-finished-600' }
|
||||
default:
|
||||
return { type: 'default', text: label, bgClass: 'bg-unpublished-100 text-unpublished-600' }
|
||||
}
|
||||
if (props.item.status === 'ended') {
|
||||
return { type: 'danger', text: '已结束', bgClass: 'bg-red-100 text-red-600' }
|
||||
}
|
||||
return { type: 'default', text: '未开始', bgClass: 'bg-gray-100 text-gray-500' }
|
||||
})
|
||||
|
||||
// 处理标题换行
|
||||
const formattedTitle = computed(() => props.item.title.replace(/\n/g, '<br/>'))
|
||||
// const formattedTitle = computed(() => props.item.title.replace(/\n/g, '<br/>'))
|
||||
|
||||
function toDetail(item: CompetitionItem) {
|
||||
routerPushByKey('competition_competition-detail', { query: { id: item.id } })
|
||||
routerPushByKey('competition_competition-detail', { query: { Id: item.Id } })
|
||||
}
|
||||
|
||||
// 随机背景色池
|
||||
@ -47,11 +59,12 @@ const bgColors = [
|
||||
'bg-pink-50/80',
|
||||
'bg-yellow-50/80',
|
||||
'bg-cyan-50/80',
|
||||
'bg-pink-50/80',
|
||||
]
|
||||
|
||||
// 基于 ID 生成确定性的随机颜色
|
||||
const decorationBgClass = computed(() => {
|
||||
const idStr = String(props.item.id || '')
|
||||
const idStr = String(props.item.Id || '')
|
||||
let hash = 0
|
||||
for (let i = 0; i < idStr.length; i++) {
|
||||
hash = idStr.charCodeAt(i) + ((hash << 5) - hash)
|
||||
@ -119,13 +132,13 @@ const decorationBgClass = computed(() => {
|
||||
<!-- 标题 -->
|
||||
<h3
|
||||
class="mb-3 min-h-[3rem] text-lg text-gray-800 font-bold leading-tight dark:text-white"
|
||||
v-html="formattedTitle"
|
||||
v-html="item.Name"
|
||||
/>
|
||||
|
||||
<!-- 日期 -->
|
||||
<div class="mb-6 flex items-center text-gray-400">
|
||||
<icon-ic-baseline-calendar-month class="mr-2 text-xl" />
|
||||
<span class="text-base font-bold">{{ item.date }} ~ {{ item.endDate }}</span>
|
||||
<span class="text-base font-bold">{{ item.StartTime }} ~ {{ item.EndTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -133,7 +146,7 @@ const decorationBgClass = computed(() => {
|
||||
<div class="flex items-center justify-between border-t border-gray-50 pt-4 dark:border-gray-800">
|
||||
<span class="flex items-center text-xs text-gray-500 font-medium">
|
||||
<icon-ic-baseline-group class="mr-2 text-xl" />
|
||||
{{ item.teamCount }} 支参赛队伍
|
||||
{{ item.TeamGroupNumber }} 支参赛队伍
|
||||
</span>
|
||||
<NButton text type="primary" size="small" class="font-bold hover:underline">
|
||||
查看配置详情
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
<script setup lang="tsx">
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { NButton, NDataTable, NForm, NFormItem, NInput, NModal, NPopconfirm, NSpace, useMessage } from 'naive-ui'
|
||||
import { h, onMounted, ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { addOrUpdateDictionary, deleteDictionary, getDictionaryList } from '@/service/api/dictionary'
|
||||
import DictionaryTypeModal from './modules/DictionaryTypeModal.vue'
|
||||
|
||||
@ -42,6 +42,7 @@ async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data: res, error } = await getDictionaryList()
|
||||
|
||||
if (!error && res) {
|
||||
tableData.value = res.data || []
|
||||
}
|
||||
@ -61,14 +62,10 @@ const columns: DataTableColumns<Api.Dictionary.DictionaryItem> = [
|
||||
title: '字典键 (Key)',
|
||||
key: 'DicKey',
|
||||
render(row) {
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
type: 'primary',
|
||||
onClick: () => handleOpenTypeModal(row),
|
||||
},
|
||||
{ default: () => row.DicKey },
|
||||
return (
|
||||
<NButton text type="primary" onClick={() => handleOpenTypeModal(row)}>
|
||||
{row.DicKey}
|
||||
</NButton>
|
||||
)
|
||||
},
|
||||
},
|
||||
@ -78,36 +75,24 @@ const columns: DataTableColumns<Api.Dictionary.DictionaryItem> = [
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render(row) {
|
||||
return h(NSpace, null, {
|
||||
default: () => [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
onClick: () => handleEdit(row),
|
||||
},
|
||||
{ default: () => '编辑' },
|
||||
),
|
||||
h(
|
||||
NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => handleDelete(row),
|
||||
},
|
||||
{
|
||||
return (
|
||||
<NSpace>
|
||||
<NButton size="small" type="primary" onClick={() => handleEdit(row)}>
|
||||
编辑
|
||||
</NButton>
|
||||
<NPopconfirm
|
||||
onPositiveClick={() => handleDelete(row)}
|
||||
v-slots={{
|
||||
default: () => '确认删除该条数据吗?',
|
||||
trigger: () => h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
},
|
||||
{ default: () => '删除' },
|
||||
trigger: () => (
|
||||
<NButton size="small" type="error">
|
||||
删除
|
||||
</NButton>
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</NSpace>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
@ -167,20 +152,19 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="h-full p-4">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 这里可以放搜索框等 -->
|
||||
</div>
|
||||
<NButton type="primary" @click="handleAdd">
|
||||
新增字典
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<NDataTable
|
||||
:columns="columns" :data="tableData" :loading="loading" :pagination="{ pageSize: 10 }"
|
||||
:row-key="(row) => row.id" bordered class="h-[calc(100%-3rem)]"
|
||||
/>
|
||||
<NCard title="字典列表" :bordered="false" page-size="small" class="card-wrapper sm:flex-1-hidden">
|
||||
<template #header-extra>
|
||||
<NButton type="primary" @click="handleAdd">
|
||||
新增字典
|
||||
</NButton>
|
||||
</template>
|
||||
|
||||
<NDataTable
|
||||
:columns="columns" :data="tableData" :loading="loading" :pagination="false"
|
||||
:row-key="(row) => row.id" bordered
|
||||
max-height="100%"
|
||||
/>
|
||||
</NCard>
|
||||
<DictionaryTypeModal
|
||||
v-model:visible="showTypeModal"
|
||||
:dictionary-id="currentDictionaryId"
|
||||
|
||||
@ -59,7 +59,7 @@ watch(() => props.visible, (val) => {
|
||||
})
|
||||
|
||||
const columns: DataTableColumns<any> = [
|
||||
{ title: 'ID', key: 'Id', width: 80 },
|
||||
{ title: 'ID', key: 'ID', width: 80 },
|
||||
{ title: '键 (Key)', key: 'UI_Key' },
|
||||
{ title: '值 (Value)', key: 'UI_Value' },
|
||||
{ title: '备注', key: 'BakValue' },
|
||||
|
||||
@ -18,7 +18,7 @@ const emit = defineEmits<{
|
||||
(e: 'dragStart', event: MouseEvent): void
|
||||
}>()
|
||||
|
||||
const { options: questionTypeOptions } = useDict('question_type')
|
||||
const { options: questionTypeOptions } = useDict('question_type', 'string')
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
@ -157,8 +157,6 @@ async function submitCategory() {
|
||||
async function fetchCategoryList() {
|
||||
try {
|
||||
const { data } = await fetchGetQuestionListAll()
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('data', data)
|
||||
categoryList.value = data?.data || []
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
|
||||
@ -276,7 +276,8 @@ onMounted(() => {
|
||||
|
||||
<!-- Right: Settings Panel -->
|
||||
<TemplateSettings
|
||||
v-model:template-info="templateInfo" v-model:gen-settings="genSettings" :regions="regions"
|
||||
v-model:template-info="templateInfo" v-model:gen-settings="genSettings" v-model:selected-region-ids="selectedRegionIds"
|
||||
:regions="regions"
|
||||
@generate="handleGenerate" @delete-region="deleteRegion" @clear-all="clearAll"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -532,7 +532,7 @@ onUnmounted(() => {
|
||||
/>
|
||||
|
||||
<!-- 标签 -->
|
||||
<span class="relative z-10 select-none text-orange-600 font-bold">{{ region.label }}</span>
|
||||
<span class="relative z-10 select-none text-60px text-orange-600 font-bold">{{ region.label }}</span>
|
||||
|
||||
<!-- 旋转手柄 -->
|
||||
<div
|
||||
|
||||
@ -8,14 +8,18 @@ const props = defineProps<{
|
||||
templateInfo: TemplateInfo
|
||||
genSettings: GenSettings
|
||||
regions: Region[]
|
||||
selectedRegionIds: Set<string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:templateInfo', info: TemplateInfo): void
|
||||
(e: 'update:genSettings', settings: GenSettings): void
|
||||
(e: 'generate'): void
|
||||
(e: 'deleteRegion', id: string): void
|
||||
(e: 'clearAll'): void
|
||||
(e: 'update:templateInfo', info: TemplateInfo): void // 更新模版基础信息
|
||||
(e: 'update:genSettings', settings: GenSettings): void // 更新生成设置
|
||||
(e: 'generate'): void // 触发生成
|
||||
(e: 'deleteRegion', id: string): void // 删除指定区域
|
||||
(e: 'clearAll'): void // 清除所有区域
|
||||
(e: 'update:selectedRegionIds', ids: Set<string>): void // 更新选中区域 ID 集合
|
||||
}>()
|
||||
|
||||
const localTemplateInfo = computed({
|
||||
get: () => props.templateInfo,
|
||||
set: val => emit('update:templateInfo', val),
|
||||
@ -25,6 +29,24 @@ const localGenSettings = computed({
|
||||
get: () => props.genSettings,
|
||||
set: val => emit('update:genSettings', val),
|
||||
})
|
||||
|
||||
/** 处理列表项点击选中 */
|
||||
function handleSelect(region: Region, e: MouseEvent) {
|
||||
const isModifierDown = e.shiftKey || e.ctrlKey || e.metaKey
|
||||
const current = new Set(props.selectedRegionIds)
|
||||
if (isModifierDown) {
|
||||
if (current.has(region.id)) {
|
||||
current.delete(region.id)
|
||||
}
|
||||
else {
|
||||
current.add(region.id)
|
||||
}
|
||||
emit('update:selectedRegionIds', current)
|
||||
}
|
||||
else {
|
||||
emit('update:selectedRegionIds', new Set([region.id]))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -117,6 +139,8 @@ const localGenSettings = computed({
|
||||
v-for="region in regions"
|
||||
:key="region.id"
|
||||
class="group-item relative max-h-[120px] overflow-auto border rounded bg-white p-3 shadow-sm transition hover:shadow-md"
|
||||
:class="props.selectedRegionIds.has(region.id) ? 'border-blue-400 ring-2 ring-blue-200' : 'border-gray-200'"
|
||||
@click="handleSelect(region, $event)"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
@ -127,7 +151,7 @@ const localGenSettings = computed({
|
||||
w:{{ region.w }},h:{{ region.h }},x:{{ region.x }},y:{{ region.y }}
|
||||
</div>
|
||||
</div>
|
||||
<NButton size="tiny" quaternary circle type="error" @click="$emit('deleteRegion', region.id)">
|
||||
<NButton size="tiny" quaternary circle type="error" @click.stop="$emit('deleteRegion', region.id)">
|
||||
<template #icon>
|
||||
<Icon icon="carbon:trash-can" />
|
||||
</template>
|
||||
|
||||
@ -35,7 +35,7 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
|
||||
data: response.data?.data || [],
|
||||
pageNum: searchParams.currentPage,
|
||||
pageSize: searchParams.pageSize,
|
||||
total: response.data?.data?.total || 0,
|
||||
total: response.data?.data?.length || 0,
|
||||
}
|
||||
return transformed
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user