feat(admin): 新增活动管理功能并优化字典组件

- 新增活动管理相关功能,包括活动创建、编辑、删除和状态管理
- 新增 PublishStatus 枚举定义活动发布状态
- 优化字典组件,支持字符串和数字类型的字典值
- 重构业务数据存储逻辑,简化字典数据获取流程
- 新增活动详情页面,支持分组管理和队伍配置
- 集成富文本编辑器用于活动规则编辑
- 优化图片上传组件,支持小图上传模式
- 修复模板管理中的区域选择和分页问题
- 更新 TypeScript 类型定义,完善 API 接口
- 调整主题配置,新增活动状态相关颜色
This commit is contained in:
2026-02-04 18:01:52 +08:00
parent e1a19c48e0
commit ce5ee2a289
39 changed files with 2621 additions and 693 deletions

View File

@ -35,6 +35,8 @@
"@sa/utils": "workspace:*",
"@sapphire/snowflake": "^3.5.5",
"@vueuse/core": "14.1.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"@zumer/snapdom": "^2.0.1",
"ali-oss": "^6.23.0",
"clipboard": "2.0.11",

View File

@ -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>

View File

@ -0,0 +1,6 @@
export enum PublishStatus {
Unpublished = 0,
Published = 1,
Processing = 2,
Finished = 3,
}

View File

@ -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 []
})

View File

@ -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,

View File

@ -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',
})
}

View File

@ -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,
},
})
}

View File

@ -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 })
})

View File

@ -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, // 头部高度

View File

@ -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

View File

@ -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
}

View File

@ -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 {

View File

@ -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']

View 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 }
}

View File

@ -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">

View File

@ -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>

View File

@ -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>

View File

@ -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[]"

View File

@ -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: '',
}
}

View File

@ -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 })

View File

@ -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,

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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">
查看配置详情

View File

@ -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"

View File

@ -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' },

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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
},

View File

@ -15,5 +15,5 @@
"src/**/*.d.ts",
"vite.config.ts"
],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "src/typings/components.d.ts"]
}

File diff suppressed because one or more lines are too long

View File

@ -27,41 +27,47 @@ export interface TableColumnCheck {
export interface UseTableOptions<ResponseData, ApiData, Column, Pagination extends boolean> {
/**
* api function to get table data
* 获取表格数据的 API 函数
*/
api: () => Promise<ResponseData>
/**
* whether to enable pagination
* 是否开启分页
*/
pagination?: Pagination
/**
* transform api response to table data
* 转换 API 响应数据为表格数据
*/
transform: Transform<ResponseData, ApiData, Pagination>
/**
* columns factory
* 列配置工厂函数
*/
columns: () => Column[]
/**
* get column checks
* 获取列的勾选状态
*/
getColumnChecks: (columns: Column[]) => TableColumnCheck[]
/**
* get columns
* 获取列配置
*/
getColumns: (columns: Column[], checks: TableColumnCheck[]) => Column[]
/**
* callback when response fetched
* 数据获取后的回调
*/
onFetched?: (data: GetApiData<ApiData, Pagination>) => void | Promise<void>
/**
* whether to get data immediately
* 是否立即获取数据
*
* @default true
*/
immediate?: boolean
}
/**
* 表格数据 hook
* 用于获取表格数据,根据是否开启分页返回不同类型的数据
* @param options 表格配置
* @returns 表格数据
*/
export default function useTable<ResponseData, ApiData, Column, Pagination extends boolean>(
options: UseTableOptions<ResponseData, ApiData, Column, Pagination>,
) {
@ -121,6 +127,12 @@ export default function useTable<ResponseData, ApiData, Column, Pagination exten
}
}
/**
* 获取表格数据,根据是否开启分页返回不同类型的数据
* @param data 转换后的 API 响应数据
* @param pagination 是否开启分页
* @returns 表格数据
*/
function getTableData<ApiData, Pagination extends boolean>(
data: GetApiData<ApiData, Pagination>,
pagination?: Pagination,

477
pnpm-lock.yaml generated
View File

@ -66,6 +66,12 @@ importers:
'@vueuse/core':
specifier: 14.1.0
version: 14.1.0(vue@3.5.26(typescript@5.9.3))
'@wangeditor/editor':
specifier: ^5.1.23
version: 5.1.23
'@wangeditor/editor-for-vue':
specifier: ^5.1.12
version: 5.1.12(@wangeditor/editor@5.1.23)(vue@3.5.26(typescript@5.9.3))
'@zumer/snapdom':
specifier: ^2.0.1
version: 2.0.2
@ -553,6 +559,10 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/runtime@7.28.6':
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'}
'@babel/template@7.27.2':
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
engines: {node: '>=6.9.0'}
@ -1327,6 +1337,9 @@ packages:
peerDependencies:
eslint: '>=8.40.0'
'@transloadit/prettier-bytes@0.0.7':
resolution: {integrity: sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==}
'@trysound/sax@0.2.0':
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
engines: {node: '>=10.13.0'}
@ -1346,6 +1359,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/event-emitter@0.3.5':
resolution: {integrity: sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@ -1641,6 +1657,23 @@ packages:
cpu: [x64]
os: [win32]
'@uppy/companion-client@2.2.2':
resolution: {integrity: sha512-5mTp2iq97/mYSisMaBtFRry6PTgZA6SIL7LePteOV5x0/DxKfrZW3DEiQERJmYpHzy7k8johpm2gHnEKto56Og==}
'@uppy/core@2.3.4':
resolution: {integrity: sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==}
'@uppy/store-default@2.1.1':
resolution: {integrity: sha512-xnpTxvot2SeAwGwbvmJ899ASk5tYXhmZzD/aCFsXePh/v8rNvR2pKlcQUH7cF/y4baUGq3FHO/daKCok/mpKqQ==}
'@uppy/utils@4.1.3':
resolution: {integrity: sha512-nTuMvwWYobnJcytDO3t+D6IkVq/Qs4Xv3vyoEZ+Iaf8gegZP+rEyoaFT2CK5XLRMienPyqRqNbIfRuFaOWSIFw==}
'@uppy/xhr-upload@2.1.3':
resolution: {integrity: sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==}
peerDependencies:
'@uppy/core': ^2.3.3
'@vitejs/plugin-vue-jsx@5.1.3':
resolution: {integrity: sha512-I6Zr8cYVr5WHMW5gNOP09DNqW9rgO8RX73Wa6Czgq/0ndpTfJM4vfDChfOT1+3KtdrNqilNBtNlFwVeB02ZzGw==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -1777,6 +1810,93 @@ packages:
peerDependencies:
vue: ^3.5.0
'@wangeditor/basic-modules@1.1.7':
resolution: {integrity: sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==}
peerDependencies:
'@wangeditor/core': 1.x
dom7: ^3.0.0
lodash.throttle: ^4.1.1
nanoid: ^3.2.0
slate: ^0.72.0
snabbdom: ^3.1.0
'@wangeditor/code-highlight@1.0.3':
resolution: {integrity: sha512-iazHwO14XpCuIWJNTQTikqUhGKyqj+dUNWJ9288Oym9M2xMVHvnsOmDU2sgUDWVy+pOLojReMPgXCsvvNlOOhw==}
peerDependencies:
'@wangeditor/core': 1.x
dom7: ^3.0.0
slate: ^0.72.0
snabbdom: ^3.1.0
'@wangeditor/core@1.1.19':
resolution: {integrity: sha512-KevkB47+7GhVszyYF2pKGKtCSj/YzmClsD03C3zTt+9SR2XWT5T0e3yQqg8baZpcMvkjs1D8Dv4fk8ok/UaS2Q==}
peerDependencies:
'@uppy/core': ^2.1.1
'@uppy/xhr-upload': ^2.0.3
dom7: ^3.0.0
is-hotkey: ^0.2.0
lodash.camelcase: ^4.3.0
lodash.clonedeep: ^4.5.0
lodash.debounce: ^4.0.8
lodash.foreach: ^4.5.0
lodash.isequal: ^4.5.0
lodash.throttle: ^4.1.1
lodash.toarray: ^4.4.0
nanoid: ^3.2.0
slate: ^0.72.0
snabbdom: ^3.1.0
'@wangeditor/editor-for-vue@5.1.12':
resolution: {integrity: sha512-0Ds3D8I+xnpNWezAeO7HmPRgTfUxHLMd9JKcIw+QzvSmhC5xUHbpCcLU+KLmeBKTR/zffnS5GQo6qi3GhTMJWQ==}
peerDependencies:
'@wangeditor/editor': '>=5.1.0'
vue: ^3.0.5
'@wangeditor/editor@5.1.23':
resolution: {integrity: sha512-0RxfeVTuK1tktUaPROnCoFfaHVJpRAIE2zdS0mpP+vq1axVQpLjM8+fCvKzqYIkH0Pg+C+44hJpe3VVroSkEuQ==}
'@wangeditor/list-module@1.0.5':
resolution: {integrity: sha512-uDuYTP6DVhcYf7mF1pTlmNn5jOb4QtcVhYwSSAkyg09zqxI1qBqsfUnveeDeDqIuptSJhkh81cyxi+MF8sEPOQ==}
peerDependencies:
'@wangeditor/core': 1.x
dom7: ^3.0.0
slate: ^0.72.0
snabbdom: ^3.1.0
'@wangeditor/table-module@1.1.4':
resolution: {integrity: sha512-5saanU9xuEocxaemGdNi9t8MCDSucnykEC6jtuiT72kt+/Hhh4nERYx1J20OPsTCCdVr7hIyQenFD1iSRkIQ6w==}
peerDependencies:
'@wangeditor/core': 1.x
dom7: ^3.0.0
lodash.isequal: ^4.5.0
lodash.throttle: ^4.1.1
nanoid: ^3.2.0
slate: ^0.72.0
snabbdom: ^3.1.0
'@wangeditor/upload-image-module@1.0.2':
resolution: {integrity: sha512-z81lk/v71OwPDYeQDxj6cVr81aDP90aFuywb8nPD6eQeECtOymrqRODjpO6VGvCVxVck8nUxBHtbxKtjgcwyiA==}
peerDependencies:
'@uppy/core': ^2.0.3
'@uppy/xhr-upload': ^2.0.3
'@wangeditor/basic-modules': 1.x
'@wangeditor/core': 1.x
dom7: ^3.0.0
lodash.foreach: ^4.5.0
slate: ^0.72.0
snabbdom: ^3.1.0
'@wangeditor/video-module@1.1.4':
resolution: {integrity: sha512-ZdodDPqKQrgx3IwWu4ZiQmXI8EXZ3hm2/fM6E3t5dB8tCaIGWQZhmqd6P5knfkRAd3z2+YRSRbxOGfoRSp/rLg==}
peerDependencies:
'@uppy/core': ^2.1.4
'@uppy/xhr-upload': ^2.0.7
'@wangeditor/core': 1.x
dom7: ^3.0.0
nanoid: ^3.2.0
slate: ^0.72.0
snabbdom: ^3.1.0
'@zumer/snapdom@2.0.2':
resolution: {integrity: sha512-W6quT4lMcPu8Q9O/Q6witSfc6/+xuY8C8yDoHug/+o7zYKCNE/e0I3//XsWDkyq9C0mDE0TAWF/8bwCR7x3gHQ==}
@ -2147,6 +2267,9 @@ packages:
component-emitter@1.3.1:
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
compute-scroll-into-view@1.0.20:
resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@ -2240,6 +2363,10 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d@1.0.2:
resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==}
engines: {node: '>=0.12'}
data-view-buffer@1.0.2:
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
engines: {node: '>= 0.4'}
@ -2372,6 +2499,9 @@ packages:
dom-serializer@1.4.1:
resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==}
dom7@3.0.0:
resolution: {integrity: sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==}
domelementtype@1.3.1:
resolution: {integrity: sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==}
@ -2486,6 +2616,17 @@ packages:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
engines: {node: '>= 0.4'}
es5-ext@0.10.64:
resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==}
engines: {node: '>=0.10'}
es6-iterator@2.0.3:
resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==}
es6-symbol@3.1.4:
resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==}
engines: {node: '>=0.12'}
esbuild@0.27.2:
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
engines: {node: '>=18'}
@ -2741,6 +2882,10 @@ packages:
jiti:
optional: true
esniff@2.0.1:
resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==}
engines: {node: '>=0.10'}
espree@10.4.0:
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -2777,6 +2922,9 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
event-emitter@0.3.5:
resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==}
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
@ -2794,6 +2942,9 @@ packages:
exsolve@1.0.8:
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
ext@1.7.0:
resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==}
extend-shallow@2.0.1:
resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
engines: {node: '>=0.10.0'}
@ -3101,6 +3252,9 @@ packages:
hosted-git-info@2.8.9:
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
html-void-elements@2.0.1:
resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==}
htmlparser2@3.10.1:
resolution: {integrity: sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==}
@ -3111,6 +3265,9 @@ packages:
humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
i18next@20.6.1:
resolution: {integrity: sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==}
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
@ -3140,6 +3297,9 @@ packages:
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
immer@9.0.21:
resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==}
immutable@5.1.4:
resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==}
@ -3272,6 +3432,9 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-hotkey@0.2.0:
resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==}
is-inside-container@1.0.0:
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
engines: {node: '>=14.16'}
@ -3309,6 +3472,10 @@ packages:
resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==}
engines: {node: '>=0.10.0'}
is-plain-object@5.0.0:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'}
@ -3351,6 +3518,9 @@ packages:
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
engines: {node: '>=18'}
is-url@1.2.4:
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
is-weakmap@2.0.2:
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
engines: {node: '>= 0.4'}
@ -3534,9 +3704,18 @@ packages:
lodash-es@4.17.22:
resolution: {integrity: sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==}
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
lodash.clonedeep@4.5.0:
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
lodash.foreach@4.5.0:
resolution: {integrity: sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==}
lodash.isequal@4.5.0:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
@ -3544,6 +3723,12 @@ packages:
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash.throttle@4.1.1:
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==}
lodash.toarray@4.4.0:
resolution: {integrity: sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw==}
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
@ -3744,6 +3929,9 @@ packages:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-match@1.0.2:
resolution: {integrity: sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
@ -3819,6 +4007,9 @@ packages:
peerDependencies:
vue: ^3.0.0
namespace-emitter@2.0.1:
resolution: {integrity: sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==}
nano-spawn@2.0.0:
resolution: {integrity: sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==}
engines: {node: '>=20.17'}
@ -3849,6 +4040,9 @@ packages:
resolution: {integrity: sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==}
engines: {node: '>=18'}
next-tick@1.1.0:
resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
@ -4183,6 +4377,9 @@ packages:
resolution: {integrity: sha512-spBB5sgC4cv2YcW03f/IAUN1pgDJWNWD8FzkyY4mArLUMJW+KlQhlmUdKAHQuPfb00Jl5xIfImeOsf6YL8QK7Q==}
engines: {node: '>=0.10.0'}
preact@10.28.3:
resolution: {integrity: sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@ -4210,6 +4407,10 @@ packages:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
prismjs@1.30.0:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'}
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
@ -4436,6 +4637,9 @@ packages:
resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==}
engines: {node: '>=11.0.0'}
scroll-into-view-if-needed@2.2.31:
resolution: {integrity: sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==}
scslre@0.3.0:
resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==}
engines: {node: ^14.0.0 || >=16.0.0}
@ -4525,10 +4729,22 @@ packages:
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
slate-history@0.66.0:
resolution: {integrity: sha512-6MWpxGQZiMvSINlCbMW43E2YBSVMCMCIwQfBzGssjWw4kb0qfvj0pIdblWNRQZD0hR6WHP+dHHgGSeVdMWzfng==}
peerDependencies:
slate: '>=0.65.3'
slate@0.72.8:
resolution: {integrity: sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw==}
slice-ansi@7.1.2:
resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==}
engines: {node: '>=18'}
snabbdom@3.6.3:
resolution: {integrity: sha512-W2lHLLw2qR2Vv0DcMmcxXqcfdBaIcoN+y/86SmHv8fn4DazEQSH6KN3TjZcWvwujW56OHiiirsbHWZb4vx/0fg==}
engines: {node: '>=12.17.0'}
snapdragon-node@2.1.1:
resolution: {integrity: sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==}
engines: {node: '>=0.10.0'}
@ -4588,6 +4804,9 @@ packages:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
ssr-window@3.0.0:
resolution: {integrity: sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==}
stable-hash-x@0.2.0:
resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==}
engines: {node: '>=12.0.0'}
@ -4749,6 +4968,9 @@ packages:
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tiny-warning@1.0.3:
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
tinyexec@1.0.2:
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
engines: {node: '>=18'}
@ -4835,6 +5057,9 @@ packages:
resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==}
engines: {node: '>=8'}
type@2.7.3:
resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==}
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@ -5195,6 +5420,9 @@ packages:
engines: {node: '>= 8'}
hasBin: true
wildcard@1.1.2:
resolution: {integrity: sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==}
win-release@1.1.1:
resolution: {integrity: sha512-iCRnKVvGxOQdsKhcQId2PXV1vV3J/sDPXKA4Oe9+Eti2nb2ESEsYHRYls/UjoUW3bIc5ZDO8dTH50A/5iVN+bw==}
engines: {node: '>=0.10.0'}
@ -5526,6 +5754,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@babel/runtime@7.28.6': {}
'@babel/template@7.27.2':
dependencies:
'@babel/code-frame': 7.27.1
@ -6180,6 +6410,8 @@ snapshots:
- supports-color
- typescript
'@transloadit/prettier-bytes@0.0.7': {}
'@trysound/sax@0.2.0': {}
'@tybys/wasm-util@0.10.1':
@ -6197,6 +6429,8 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/event-emitter@0.3.5': {}
'@types/json-schema@7.0.15': {}
'@types/katex@0.16.7': {}
@ -6530,6 +6764,35 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true
'@uppy/companion-client@2.2.2':
dependencies:
'@uppy/utils': 4.1.3
namespace-emitter: 2.0.1
'@uppy/core@2.3.4':
dependencies:
'@transloadit/prettier-bytes': 0.0.7
'@uppy/store-default': 2.1.1
'@uppy/utils': 4.1.3
lodash.throttle: 4.1.1
mime-match: 1.0.2
namespace-emitter: 2.0.1
nanoid: 3.3.11
preact: 10.28.3
'@uppy/store-default@2.1.1': {}
'@uppy/utils@4.1.3':
dependencies:
lodash.throttle: 4.1.1
'@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4)':
dependencies:
'@uppy/companion-client': 2.2.2
'@uppy/core': 2.3.4
'@uppy/utils': 4.1.3
nanoid: 3.3.11
'@vitejs/plugin-vue-jsx@5.1.3(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))':
dependencies:
'@babel/core': 7.28.5
@ -6751,6 +7014,114 @@ snapshots:
dependencies:
vue: 3.5.26(typescript@5.9.3)
'@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)':
dependencies:
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
dom7: 3.0.0
is-url: 1.2.4
lodash.throttle: 4.1.1
nanoid: 3.3.11
slate: 0.72.8
snabbdom: 3.6.3
'@wangeditor/code-highlight@1.0.3(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.3)':
dependencies:
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
dom7: 3.0.0
prismjs: 1.30.0
slate: 0.72.8
snabbdom: 3.6.3
'@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)':
dependencies:
'@types/event-emitter': 0.3.5
'@uppy/core': 2.3.4
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
dom7: 3.0.0
event-emitter: 0.3.5
html-void-elements: 2.0.1
i18next: 20.6.1
is-hotkey: 0.2.0
lodash.camelcase: 4.3.0
lodash.clonedeep: 4.5.0
lodash.debounce: 4.0.8
lodash.foreach: 4.5.0
lodash.isequal: 4.5.0
lodash.throttle: 4.1.1
lodash.toarray: 4.4.0
nanoid: 3.3.11
scroll-into-view-if-needed: 2.2.31
slate: 0.72.8
slate-history: 0.66.0(slate@0.72.8)
snabbdom: 3.6.3
'@wangeditor/editor-for-vue@5.1.12(@wangeditor/editor@5.1.23)(vue@3.5.26(typescript@5.9.3))':
dependencies:
'@wangeditor/editor': 5.1.23
vue: 3.5.26(typescript@5.9.3)
'@wangeditor/editor@5.1.23':
dependencies:
'@uppy/core': 2.3.4
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
'@wangeditor/basic-modules': 1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
'@wangeditor/code-highlight': 1.0.3(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.3)
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
'@wangeditor/list-module': 1.0.5(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.3)
'@wangeditor/table-module': 1.1.4(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
'@wangeditor/upload-image-module': 1.0.2(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.foreach@4.5.0)(slate@0.72.8)(snabbdom@3.6.3)
'@wangeditor/video-module': 1.1.4(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
dom7: 3.0.0
is-hotkey: 0.2.0
lodash.camelcase: 4.3.0
lodash.clonedeep: 4.5.0
lodash.debounce: 4.0.8
lodash.foreach: 4.5.0
lodash.isequal: 4.5.0
lodash.throttle: 4.1.1
lodash.toarray: 4.4.0
nanoid: 3.3.11
slate: 0.72.8
snabbdom: 3.6.3
'@wangeditor/list-module@1.0.5(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.3)':
dependencies:
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
dom7: 3.0.0
slate: 0.72.8
snabbdom: 3.6.3
'@wangeditor/table-module@1.1.4(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)':
dependencies:
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
dom7: 3.0.0
lodash.isequal: 4.5.0
lodash.throttle: 4.1.1
nanoid: 3.3.11
slate: 0.72.8
snabbdom: 3.6.3
'@wangeditor/upload-image-module@1.0.2(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.foreach@4.5.0)(slate@0.72.8)(snabbdom@3.6.3)':
dependencies:
'@uppy/core': 2.3.4
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
'@wangeditor/basic-modules': 1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
dom7: 3.0.0
lodash.foreach: 4.5.0
slate: 0.72.8
snabbdom: 3.6.3
'@wangeditor/video-module@1.1.4(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)':
dependencies:
'@uppy/core': 2.3.4
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
dom7: 3.0.0
nanoid: 3.3.11
slate: 0.72.8
snabbdom: 3.6.3
'@zumer/snapdom@2.0.2': {}
acorn-jsx@5.3.2(acorn@8.15.0):
@ -7160,6 +7531,8 @@ snapshots:
component-emitter@1.3.1: {}
compute-scroll-into-view@1.0.20: {}
concat-map@0.0.1: {}
confbox@0.1.8: {}
@ -7240,6 +7613,11 @@ snapshots:
csstype@3.2.3: {}
d@1.0.2:
dependencies:
es5-ext: 0.10.64
type: 2.7.3
data-view-buffer@1.0.2:
dependencies:
call-bound: 1.0.4
@ -7356,6 +7734,10 @@ snapshots:
domhandler: 4.3.1
entities: 2.2.0
dom7@3.0.0:
dependencies:
ssr-window: 3.0.0
domelementtype@1.3.1: {}
domelementtype@2.3.0: {}
@ -7516,6 +7898,24 @@ snapshots:
is-date-object: 1.1.0
is-symbol: 1.1.1
es5-ext@0.10.64:
dependencies:
es6-iterator: 2.0.3
es6-symbol: 3.1.4
esniff: 2.0.1
next-tick: 1.1.0
es6-iterator@2.0.3:
dependencies:
d: 1.0.2
es5-ext: 0.10.64
es6-symbol: 3.1.4
es6-symbol@3.1.4:
dependencies:
d: 1.0.2
ext: 1.7.0
esbuild@0.27.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.2
@ -7889,6 +8289,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
esniff@2.0.1:
dependencies:
d: 1.0.2
es5-ext: 0.10.64
event-emitter: 0.3.5
type: 2.7.3
espree@10.4.0:
dependencies:
acorn: 8.15.0
@ -7919,6 +8326,11 @@ snapshots:
etag@1.8.1: {}
event-emitter@0.3.5:
dependencies:
d: 1.0.2
es5-ext: 0.10.64
eventemitter3@5.0.1: {}
evtd@0.2.4: {}
@ -7952,6 +8364,10 @@ snapshots:
exsolve@1.0.8: {}
ext@1.7.0:
dependencies:
type: 2.7.3
extend-shallow@2.0.1:
dependencies:
is-extendable: 0.1.1
@ -8263,6 +8679,8 @@ snapshots:
hosted-git-info@2.8.9: {}
html-void-elements@2.0.1: {}
htmlparser2@3.10.1:
dependencies:
domelementtype: 1.3.1
@ -8278,6 +8696,10 @@ snapshots:
dependencies:
ms: 2.1.3
i18next@20.6.1:
dependencies:
'@babel/runtime': 7.28.6
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
@ -8296,6 +8718,8 @@ snapshots:
immediate@3.0.6: {}
immer@9.0.21: {}
immutable@5.1.4: {}
import-fresh@3.3.1:
@ -8425,6 +8849,8 @@ snapshots:
dependencies:
is-extglob: 2.1.1
is-hotkey@0.2.0: {}
is-inside-container@1.0.0:
dependencies:
is-docker: 3.0.0
@ -8452,6 +8878,8 @@ snapshots:
dependencies:
isobject: 3.0.1
is-plain-object@5.0.0: {}
is-regex@1.2.1:
dependencies:
call-bound: 1.0.4
@ -8494,6 +8922,8 @@ snapshots:
is-unicode-supported@2.1.0: {}
is-url@1.2.4: {}
is-weakmap@2.0.2: {}
is-weakref@1.1.1:
@ -8660,12 +9090,22 @@ snapshots:
lodash-es@4.17.22: {}
lodash.camelcase@4.3.0: {}
lodash.clonedeep@4.5.0: {}
lodash.debounce@4.0.8: {}
lodash.foreach@4.5.0: {}
lodash.isequal@4.5.0: {}
lodash.merge@4.6.2: {}
lodash.throttle@4.1.1: {}
lodash.toarray@4.4.0: {}
lodash@4.17.21: {}
log-update@6.1.0:
@ -9063,6 +9503,10 @@ snapshots:
mime-db@1.52.0: {}
mime-match@1.0.2:
dependencies:
wildcard: 1.1.2
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
@ -9146,6 +9590,8 @@ snapshots:
vue: 3.5.26(typescript@5.9.3)
vueuc: 0.4.65(vue@3.5.26(typescript@5.9.3))
namespace-emitter@2.0.1: {}
nano-spawn@2.0.0: {}
nanoid@3.3.11: {}
@ -9174,6 +9620,8 @@ snapshots:
natural-orderby@5.0.0: {}
next-tick@1.1.0: {}
node-addon-api@7.1.1:
optional: true
@ -9490,6 +9938,8 @@ snapshots:
posthtml-parser: 0.2.1
posthtml-render: 1.4.0
preact@10.28.3: {}
prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.0:
@ -9508,6 +9958,8 @@ snapshots:
dependencies:
parse-ms: 4.0.0
prismjs@1.30.0: {}
process-nextick-args@2.0.1: {}
progress@2.0.3: {}
@ -9774,6 +10226,10 @@ snapshots:
sax@1.4.4: {}
scroll-into-view-if-needed@2.2.31:
dependencies:
compute-scroll-into-view: 1.0.20
scslre@0.3.0:
dependencies:
'@eslint-community/regexpp': 4.12.2
@ -9882,11 +10338,24 @@ snapshots:
sisteransi@1.0.5: {}
slate-history@0.66.0(slate@0.72.8):
dependencies:
is-plain-object: 5.0.0
slate: 0.72.8
slate@0.72.8:
dependencies:
immer: 9.0.21
is-plain-object: 5.0.0
tiny-warning: 1.0.3
slice-ansi@7.1.2:
dependencies:
ansi-styles: 6.2.3
is-fullwidth-code-point: 5.1.0
snabbdom@3.6.3: {}
snapdragon-node@2.1.1:
dependencies:
define-property: 1.0.0
@ -9955,6 +10424,8 @@ snapshots:
dependencies:
frac: 1.1.2
ssr-window@3.0.0: {}
stable-hash-x@0.2.0: {}
stable@0.1.8: {}
@ -10136,6 +10607,8 @@ snapshots:
tiny-invariant@1.3.3: {}
tiny-warning@1.0.3: {}
tinyexec@1.0.2: {}
tinyglobby@0.2.15:
@ -10213,6 +10686,8 @@ snapshots:
type-fest@0.8.1: {}
type@2.7.3: {}
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
@ -10683,6 +11158,8 @@ snapshots:
dependencies:
isexe: 2.0.0
wildcard@1.1.2: {}
win-release@1.1.1:
dependencies:
semver: 5.7.2