feat(admin): 新增活动管理功能并优化字典组件
- 新增活动管理相关功能,包括活动创建、编辑、删除和状态管理 - 新增 PublishStatus 枚举定义活动发布状态 - 优化字典组件,支持字符串和数字类型的字典值 - 重构业务数据存储逻辑,简化字典数据获取流程 - 新增活动详情页面,支持分组管理和队伍配置 - 集成富文本编辑器用于活动规则编辑 - 优化图片上传组件,支持小图上传模式 - 修复模板管理中的区域选择和分页问题 - 更新 TypeScript 类型定义,完善 API 接口 - 调整主题配置,新增活动状态相关颜色
This commit is contained in:
@ -2,7 +2,7 @@
|
||||
import { NButton, NModal, NStep, NSteps } from 'naive-ui'
|
||||
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useDict } from '@/hooks/business/useDict'
|
||||
import { fetchCreateActivity, fetchCreateQuestion, fetchCreateTeamList, fetchGetRoomList } from '@/service/api/competition'
|
||||
import { fetchCreateCompetition, fetchGetRoomList } from '@/service/api/competition'
|
||||
/** 房间相关接口 */
|
||||
import BasicInfo from './modules/BasicInfo.vue'
|
||||
|
||||
@ -41,17 +41,20 @@ const competitionData = ref({
|
||||
teamCount: 4, // 每组队伍数量
|
||||
poster: '', // 竞赛海报
|
||||
roomId: null, // 关联场地AP
|
||||
subTitle: '星·辞海遨游', // 活动口号或副标题
|
||||
extraTimeContent: '每道题额外时间10秒', // 加时赛的规则
|
||||
activityContent: '<p>本轮环节共有4道题目,分别是“<strong>诗词理解</strong>”、“<strong>联想对对碰</strong>”、“<strong>逆向接诗句</strong>”、“<strong>情景猜诗句</strong>”</p><p>阅读完题目介绍,主持人点击“<strong>开始作答</strong>”后均在答题本田字格上作答</p><p><br></p><p> “<strong>诗词理解</strong>” 根据题目选择正确答案</p><p> “<strong>联想对对碰</strong>” 根据关键信息写出完整诗句</p><p> “<strong>逆向接诗句</strong>” 根据关键信息写出关联诗句</p><p> “<strong>情景猜诗句</strong>” 根据情景写出关联诗句</p><p><br></p><p>答题倒计时结束后,界面自动跳转到下一题</p>',
|
||||
rounds: [ // 赛题包配置
|
||||
{
|
||||
id: 'round_default',
|
||||
name: '星·辞海遨游',
|
||||
roundType: roundTypeOptions.value?.[0]?.value,
|
||||
roundType: roundTypeOptions.value?.[0]?.value || 0,
|
||||
questions: Array.from({ length: 10 }).map(() => ({
|
||||
value: null,
|
||||
score: 5,
|
||||
time: 30,
|
||||
title: '第1题',
|
||||
scoreType: scoreTypeOptions.value?.[0]?.value,
|
||||
scoreType: scoreTypeOptions.value?.[0]?.value || 0,
|
||||
})),
|
||||
},
|
||||
],
|
||||
@ -93,7 +96,7 @@ async function nextStep() {
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
const { name, startTime, endTime, groupCount, teamCount, poster, rounds, groupManagement, roomId } = competitionData.value
|
||||
const { name, startTime, endTime, groupCount, teamCount, poster, rounds, groupManagement, roomId, subTitle, extraTimeContent, activityContent } = competitionData.value
|
||||
|
||||
if (!startTime || !endTime) {
|
||||
window.$message?.error('请完善比赛时间')
|
||||
@ -107,10 +110,14 @@ async function submitForm() {
|
||||
startTime,
|
||||
endTime,
|
||||
teams: groupCount * teamCount,
|
||||
teamGroupNumber: groupCount,
|
||||
teamGroupNumber: teamCount,
|
||||
createdTime: startTime,
|
||||
roomID: roomId || 0,
|
||||
backgroundImg: poster,
|
||||
publishStatus: 0,
|
||||
ActivityTitle: subTitle,
|
||||
ActivityContent: activityContent,
|
||||
ExtraTimeContent: extraTimeContent,
|
||||
}
|
||||
const teamParams: Api.Competition.CreateTeamListRequest[] = []
|
||||
|
||||
@ -123,11 +130,11 @@ async function submitForm() {
|
||||
id: 0,
|
||||
mainId: 0,
|
||||
number: `${group.id}-${index + 1}`,
|
||||
name,
|
||||
name: team.name,
|
||||
penSerial: `${group.id}-${index + 1}`,
|
||||
nameList: 'to do?',
|
||||
schoolName: name,
|
||||
teamGroupId: group.id && Number(group.id),
|
||||
nameList: `${index + 1}-${team.name}`,
|
||||
schoolName: team.name,
|
||||
teamGroupId: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -141,27 +148,38 @@ async function submitForm() {
|
||||
questionParams.push({
|
||||
id: 0,
|
||||
activityID: 0,
|
||||
questionID: question.questionId,
|
||||
questionIndex: index + 1,
|
||||
actitvityQuestionName: question.title,
|
||||
questionTime: question.time,
|
||||
questionRule: question.scoreType,
|
||||
uiType: question.uiType,
|
||||
point: question.score,
|
||||
templateID: question.templateId,
|
||||
questionID: question.QuestionID,
|
||||
questionIndex: index,
|
||||
actitvityQuestionName: question.ActitvityQuestionName,
|
||||
questionTime: question.QuestionTime,
|
||||
questionRule: question.QuestionRule,
|
||||
uiType: question.UIType,
|
||||
point: question.Point,
|
||||
templateID: question.TemplateID,
|
||||
roundType: round.roundType,
|
||||
questionSubTitle: question.title,
|
||||
questionSubTitle: question.ActitvityQuestionName,
|
||||
})
|
||||
})
|
||||
})
|
||||
// 使用promise.all 提交数据
|
||||
const [competitionRes, teamRes, questionRes] = await Promise.all([
|
||||
fetchCreateActivity(roomParams),
|
||||
fetchCreateTeamList(teamParams),
|
||||
fetchCreateQuestion(questionParams),
|
||||
|
||||
])
|
||||
if (competitionRes && teamRes && questionRes) {
|
||||
// 根据 questionID 降序排序 (例如: 54455 -> 55544)
|
||||
questionParams.sort((a, b) => Number(b.questionID) - Number(a.questionID))
|
||||
|
||||
// 重新计算 questionIndex
|
||||
questionParams.forEach((item, index) => {
|
||||
item.questionIndex = index
|
||||
})
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(questionParams, 'questionParams')
|
||||
|
||||
const { error } = await fetchCreateCompetition({
|
||||
actMain: roomParams,
|
||||
teams: teamParams,
|
||||
questions: questionParams,
|
||||
})
|
||||
|
||||
if (!error) {
|
||||
window.$message?.success('比赛发布成功')
|
||||
emit('success')
|
||||
emit('update:show', false)
|
||||
@ -216,7 +234,7 @@ watch(() => competitionData.value, (val) => {
|
||||
<template>
|
||||
<NModal :show="show" :mask-closable="false" @update:show="handleClose">
|
||||
<div
|
||||
class="relative h-[90vh] max-h-[980px] max-w-[95vw] w-[1280px] flex flex-col overflow-hidden rounded-[2rem] bg-white shadow-2xl transition-all"
|
||||
class="relative h-[90vh] max-h-[1080px] max-w-[95vw] w-[1380px] flex flex-col overflow-hidden rounded-[2rem] bg-white shadow-2xl transition-all"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-10 py-8">
|
||||
|
||||
@ -1,18 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { NDatePicker, NForm, NInput, NInputNumber, NSelect } from 'naive-ui'
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { NAlert, NDatePicker, NForm, NInput, NInputNumber, NSelect } from 'naive-ui'
|
||||
import { onBeforeUnmount, ref, shallowRef, useTemplateRef } from 'vue'
|
||||
import OssImageUpload from '@/components/common/oss-image-upload/index.vue'
|
||||
import { useBasicInfo } from './useBasicInfo'
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: {
|
||||
name: string
|
||||
subTitle?: string
|
||||
startTime: string | null
|
||||
endTime: string | null
|
||||
groupCount: number
|
||||
teamCount: number
|
||||
poster: string
|
||||
roomId: number | null
|
||||
activityContent: string
|
||||
extraTimeContent: string
|
||||
[key: string]: any
|
||||
}
|
||||
/** 房间列表 */
|
||||
@ -25,24 +30,44 @@ const formRef = useTemplateRef('formRef')
|
||||
|
||||
const { formData, updateCount, validate, reset } = useBasicInfo(props, emit)
|
||||
|
||||
// Editor Logic
|
||||
const editorRef = shallowRef()
|
||||
const mode = 'default'
|
||||
const toolbarConfig = {
|
||||
excludeKeys: ['group-video', 'insertVideo', 'uploadVideo'],
|
||||
}
|
||||
const editorConfig = { placeholder: '请输入具体的比赛参与规则及计分标准...' }
|
||||
const activeTab = ref('normal') // normal or extra
|
||||
|
||||
function handleCreated(editor: any) {
|
||||
editorRef.value = editor
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value
|
||||
if (editor == null)
|
||||
return
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
defineExpose({ validate, reset })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex gap-12">
|
||||
<div class="h-full flex gap-12 overflow-hidden">
|
||||
<!-- 左侧表单区域 -->
|
||||
<div class="flex-1">
|
||||
<div class="mb-12">
|
||||
<h2 class="mb-4 text-4xl text-gray-800 font-bold">
|
||||
<div class="no-scrollbar h-full flex-1 overflow-y-auto">
|
||||
<div class="mb-6">
|
||||
<h2 class="mb-2 text-3xl text-gray-800 font-bold">
|
||||
第一步: 基础定义
|
||||
</h2>
|
||||
<p class="text-lg text-gray-400 font-bold">
|
||||
<p class="text-16px text-gray-400 font-bold">
|
||||
请确认比赛名称与参与规模。这将作为系统自动生成分组与终端连接数的依据。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<NForm ref="formRef" label-placement="top" :show-feedback="false">
|
||||
<div class="mb-10">
|
||||
<div class="mb-6">
|
||||
<div class="mb-3 text-sm text-[#8DA5C3] font-bold">
|
||||
比赛项目名称
|
||||
<span class="text-red-500">*</span>
|
||||
@ -53,7 +78,18 @@ defineExpose({ validate, reset })
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-10">
|
||||
<div class="mb-6">
|
||||
<div class="mb-3 text-sm text-[#8DA5C3] font-bold">
|
||||
活动副标题
|
||||
<span class="ml-2 text-gray-400 font-normal">(选填)</span>
|
||||
</div>
|
||||
<NInput
|
||||
v-model:value="formData.subTitle" placeholder="请输入活动口号或副标题"
|
||||
class="h-[3rem] rounded-2xl border-none text-xl leading-[3rem] shadow-[0_2px_10px_rgba(0,0,0,0.02)] !bg-[#FCFCFC]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<div class="mb-3 text-sm text-[#8DA5C3] font-bold">
|
||||
关联场地AP
|
||||
<span class="text-red-500">*</span>
|
||||
@ -66,10 +102,10 @@ defineExpose({ validate, reset })
|
||||
:theme-overrides="{
|
||||
peers: {
|
||||
InternalSelection: {
|
||||
heightLarge: '3.4rem',
|
||||
heightLarge: '3rem',
|
||||
color: '#FCFCFC',
|
||||
borderRadius: '1rem',
|
||||
fontSizeLarge: '1.25rem',
|
||||
fontSizeLarge: '1.2rem',
|
||||
borderHover: 'none',
|
||||
borderFocus: 'none',
|
||||
boxShadowHover: 'none',
|
||||
@ -95,8 +131,8 @@ defineExpose({ validate, reset })
|
||||
Input: {
|
||||
borderRadius: '1rem',
|
||||
color: '#FCFCFC',
|
||||
heightLarge: '3.4rem',
|
||||
fontSizeLarge: '1.25rem',
|
||||
heightLarge: '3rem',
|
||||
fontSizeLarge: '1.2rem',
|
||||
textColor: '#1f2937',
|
||||
borderHover: 'none',
|
||||
borderFocus: 'none',
|
||||
@ -118,7 +154,7 @@ defineExpose({ validate, reset })
|
||||
Input: {
|
||||
borderRadius: '1rem',
|
||||
color: '#FCFCFC',
|
||||
heightLarge: '3.4rem',
|
||||
heightLarge: '3rem',
|
||||
fontSizeLarge: '1.25rem',
|
||||
textColor: '#1f2937',
|
||||
borderHover: 'none',
|
||||
@ -222,22 +258,81 @@ defineExpose({ validate, reset })
|
||||
</NForm>
|
||||
</div>
|
||||
|
||||
<!-- 右侧海报上传区域 -->
|
||||
<div class="w-99 flex flex-col pt-32">
|
||||
<div class="mb-3 w-full text-sm text-[#8DA5C3] font-bold">
|
||||
活动海报
|
||||
<NAlert type="info" class="mt-2 text-xs text-[#616263] font-normal">
|
||||
<span class="text-xs font-normal">
|
||||
建议上传 16:9 或 4:3 比例的图片,以获得最佳的全屏显示效果。
|
||||
</span>
|
||||
</NAlert>
|
||||
<!-- 右侧区域:规则编辑与海报 -->
|
||||
<div class="no-scrollbar h-full flex flex-col flex-1 gap-10 overflow-y-auto pr-2">
|
||||
<!-- 活动规则 -->
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="text-sm text-[#8DA5C3] font-bold">
|
||||
活动规则配置
|
||||
<span class="text-red-500">*</span>
|
||||
</div>
|
||||
<!-- 切换 Tab -->
|
||||
<div class="flex rounded-lg bg-gray-100 p-1">
|
||||
<button
|
||||
class="rounded-md px-4 py-1.5 text-sm font-medium transition-all"
|
||||
:class="activeTab === 'normal' ? 'bg-[#3B82F6] text-white shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||
@click="activeTab = 'normal'"
|
||||
>
|
||||
普通赛事
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md px-4 py-1.5 text-sm font-medium transition-all"
|
||||
:class="activeTab === 'extra' ? 'bg-[#3B82F6] text-white shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||
@click="activeTab = 'extra'"
|
||||
>
|
||||
加时赛
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden border border-gray-100 rounded-3xl bg-white shadow-sm">
|
||||
<Toolbar
|
||||
style="border-bottom: 1px solid #eee"
|
||||
:editor="editorRef"
|
||||
:default-config="toolbarConfig"
|
||||
:mode="mode"
|
||||
/>
|
||||
<div class="relative h-[400px]">
|
||||
<div v-show="activeTab === 'normal'" class="h-full">
|
||||
<Editor
|
||||
v-model="formData.activityContent"
|
||||
style="height: 100%; overflow-y: hidden;"
|
||||
:default-config="{ ...editorConfig, placeholder: '请输入普通赛事的参与规则及计分标准...' }"
|
||||
:mode="mode"
|
||||
@on-created="handleCreated"
|
||||
/>
|
||||
</div>
|
||||
<div v-show="activeTab === 'extra'" class="h-full">
|
||||
<Editor
|
||||
v-model="formData.extraTimeContent"
|
||||
style="height: 100%; overflow-y: hidden;"
|
||||
:default-config="{ ...editorConfig, placeholder: '请输入加时赛的参与规则及计分标准...' }"
|
||||
:mode="mode"
|
||||
@on-created="handleCreated"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 海报上传 -->
|
||||
<div class="w-full">
|
||||
<div class="mb-3 w-full text-sm text-[#8DA5C3] font-bold">
|
||||
活动海报
|
||||
<NAlert type="info" class="mt-2 text-xs text-[#616263] font-normal" :bordered="false">
|
||||
<span class="text-xs font-normal">
|
||||
建议上传 16:9 或 4:3 比例的图片,以获得最佳的全屏显示效果。
|
||||
</span>
|
||||
</NAlert>
|
||||
</div>
|
||||
<OssImageUpload
|
||||
v-model="formData.poster"
|
||||
hint="支持 JPG/PNG 格式"
|
||||
accept="image/*"
|
||||
:max-size="5120"
|
||||
/>
|
||||
</div>
|
||||
<OssImageUpload
|
||||
v-model="formData.poster"
|
||||
hint="支持 JPG/PNG 格式"
|
||||
accept="image/*"
|
||||
:max-size="5120"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -252,4 +347,12 @@ defineExpose({ validate, reset })
|
||||
:deep(.n-upload-trigger) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import { debounce } from '@sa/utils'
|
||||
import { NButton, NGrid, NGridItem, NInput } from 'naive-ui'
|
||||
import { toRaw, watch } from 'vue'
|
||||
import OssImageUpload from '@/components/common/oss-image-upload/index.vue'
|
||||
import { useExcelExport, useExcelImport, useGroupManagement } from './useGroupManagement'
|
||||
|
||||
const props = defineProps<{
|
||||
@ -79,10 +80,20 @@ defineExpose({ validate, reset })
|
||||
<div class="mb-1 text-xs text-gray-400 font-bold">
|
||||
<!-- 显示分组序号,从1到teamCount -->
|
||||
TEAM {{ tIndex + 1 }}
|
||||
<!-- // 显示分组序号,从1到groups.length*teamCount -->
|
||||
<!-- TEAM {{ (index + 1) * (props.config?.teamCount ?? 0) - (props.config?.teamCount ?? 0) + tIndex + 1 }} -->
|
||||
</div>
|
||||
<NInput v-model:value="team.name" placeholder="输入队伍名称" class="rounded-lg !bg-white" />
|
||||
<div class="flex items-center gap-3">
|
||||
<OssImageUpload
|
||||
v-model="team.headImg"
|
||||
variant="mini"
|
||||
class="h-10 w-10 shrink-0"
|
||||
/>
|
||||
<!-- 队伍名称输入框 -->
|
||||
<NInput
|
||||
v-model:value="team.name"
|
||||
placeholder="输入队伍名称"
|
||||
class="flex-1 rounded-lg !bg-white"
|
||||
/>
|
||||
</div>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
</div>
|
||||
|
||||
@ -12,16 +12,16 @@ const props = defineProps<{
|
||||
rounds: Array<{
|
||||
id: string
|
||||
name: string
|
||||
roundType: string
|
||||
roundType: number
|
||||
questions: Array<{
|
||||
id: string
|
||||
questionId: string | null
|
||||
time: number
|
||||
title: string
|
||||
scoreType: Api.Competition.QuestionScoreType
|
||||
score: number
|
||||
uiType?: string
|
||||
templateId?: number
|
||||
QuestionID: number
|
||||
QuestionTime: number
|
||||
ActitvityQuestionName: string
|
||||
QuestionRule: number
|
||||
Point: number
|
||||
UIType?: string
|
||||
TemplateID?: number
|
||||
}>
|
||||
}>
|
||||
}
|
||||
@ -30,7 +30,7 @@ const props = defineProps<{
|
||||
const { options: roundTypeOptions } = useDict('round_type')
|
||||
const { options: scoreTypeOptions } = useDict('score_type')
|
||||
const { options: timeOptions } = useDict('time_out')
|
||||
const { options: uiTypeOptions } = useDict('ui_type')
|
||||
const { options: uiTypeOptions } = useDict('ui_type', 'string')
|
||||
|
||||
const {
|
||||
// getRoundScore,
|
||||
@ -39,6 +39,8 @@ const {
|
||||
addRound,
|
||||
removeRound,
|
||||
addQuestion,
|
||||
handleDragEnd,
|
||||
handleTypeChange,
|
||||
removeQuestion,
|
||||
validate,
|
||||
reset,
|
||||
@ -47,30 +49,25 @@ const {
|
||||
const questionOptions = ref<SelectOption[]>([])
|
||||
|
||||
const templateIdOptions = ref<SelectOption[]>([])
|
||||
const isDataLoaded = ref(false)
|
||||
|
||||
// 模态框控制
|
||||
const showAddModal = ref(false)
|
||||
const newRoundForm = ref({
|
||||
name: '',
|
||||
count: 10,
|
||||
roundType: roundTypeOptions?.value?.[0].value || '',
|
||||
roundType: roundTypeOptions?.value?.[0]?.value || 0,
|
||||
})
|
||||
|
||||
function handleOpenAddModal() {
|
||||
newRoundForm.value = {
|
||||
name: `第${(props.modelValue?.rounds?.length || 0) + 1}轮 星·诗词大会`,
|
||||
count: 10,
|
||||
roundType: roundTypeOptions?.value?.[0].value || '',
|
||||
roundType: roundTypeOptions?.value?.[0]?.value || 0,
|
||||
}
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
function handleConfirmAdd() {
|
||||
if (!newRoundForm.value.name) {
|
||||
window.$message?.warning('请输入环节名称')
|
||||
return
|
||||
}
|
||||
addRound(newRoundForm.value.name, newRoundForm.value.count, newRoundForm.value.roundType as Api.Competition.CompetitionRoundType)
|
||||
addRound(undefined, newRoundForm.value.count, newRoundForm.value.roundType as Api.Competition.CompetitionRoundType)
|
||||
showAddModal.value = false
|
||||
}
|
||||
|
||||
@ -123,8 +120,11 @@ async function getAllTemplates() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getAllQuestions()
|
||||
getAllTemplates()
|
||||
if (!isDataLoaded.value) {
|
||||
getAllQuestions()
|
||||
getAllTemplates()
|
||||
isDataLoaded.value = true
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({ validate, reset })
|
||||
@ -202,10 +202,9 @@ defineExpose({ validate, reset })
|
||||
{{ roundTypeOptions.find((item) => item.value === round.roundType)?.label || '未知类型' }}
|
||||
</NTag>
|
||||
<div class="flex items-center gap-3">
|
||||
<NInput
|
||||
v-model:value="round.name" placeholder="请输入环节名称" :bordered="false"
|
||||
class="border transition-all !w-80 !border-transparent !rounded-lg !bg-transparent !text-xl !font-bold focus-within:shadow-sm focus-within:!border-blue-500 hover:!border-gray-200 focus-within:!bg-white"
|
||||
/>
|
||||
<div class="w-80 truncate text-xl text-gray-800 font-bold">
|
||||
{{ round.name }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-500 font-bold"
|
||||
@ -251,6 +250,7 @@ defineExpose({ validate, reset })
|
||||
<VueDraggable
|
||||
v-model="round.questions" :animation="300" handle=".drag-handle" class="flex flex-col gap-3"
|
||||
ghost-class="ghost"
|
||||
@end="(evt) => handleDragEnd(roundIndex, evt)"
|
||||
>
|
||||
<div
|
||||
v-for="(item, qIdx) in round.questions" :key="item.id"
|
||||
@ -268,7 +268,8 @@ defineExpose({ validate, reset })
|
||||
<span
|
||||
class="w-6 flex-shrink-0 text-center text-sm text-gray-300 font-bold transition-colors group-hover/item:text-blue-500"
|
||||
>
|
||||
{{ String(qIdx + 1).padStart(2, '0') }}
|
||||
<!-- {{ String(qIdx + 1).padStart(2, '0') }} - -->
|
||||
<NTag type="primary" class="!text-blue-500">{{ item.QuestionID }}</NTag>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -277,7 +278,7 @@ defineExpose({ validate, reset })
|
||||
<icon-ic-outline-title class="text-gray-400" />
|
||||
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">标题:</span>
|
||||
<NInput
|
||||
v-model:value="item.title" class="flex-1 !bg-transparent" size="small" :bordered="false"
|
||||
v-model:value="item.ActitvityQuestionName" class="flex-1 !bg-transparent" size="small" :bordered="false"
|
||||
placeholder="请输入题目标题"
|
||||
/>
|
||||
</div>
|
||||
@ -297,15 +298,16 @@ defineExpose({ validate, reset })
|
||||
<!-- 题型 -->
|
||||
<div class="col-span-4">
|
||||
<NSelect
|
||||
v-model:value="item.questionId" :options="questionOptions" placeholder="请选择题目类型" size="small"
|
||||
v-model:value="item.QuestionID" :options="questionOptions" placeholder="请选择题目类型" size="small"
|
||||
class="font-medium"
|
||||
@update:value="() => handleTypeChange(roundIndex)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- UItype -->
|
||||
<div class="col-span-4">
|
||||
<NSelect
|
||||
v-model:value="item.uiType" :options="uiTypeOptions" placeholder="请选择UI类型" size="small"
|
||||
v-model:value="item.UIType" :options="uiTypeOptions" placeholder="请选择UI类型" size="small"
|
||||
class="font-medium"
|
||||
/>
|
||||
</div>
|
||||
@ -313,7 +315,7 @@ defineExpose({ validate, reset })
|
||||
<!-- templateId -->
|
||||
<div class="col-span-4">
|
||||
<NSelect
|
||||
v-model:value="item.templateId" :options="templateIdOptions" placeholder="请选择模板" size="small"
|
||||
v-model:value="item.TemplateID" :options="templateIdOptions" placeholder="请选择模板" size="small"
|
||||
class="font-medium"
|
||||
/>
|
||||
</div>
|
||||
@ -323,7 +325,7 @@ defineExpose({ validate, reset })
|
||||
<icon-ic-outline-timer class="text-gray-400" />
|
||||
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">答题时间:</span>
|
||||
<NSelect
|
||||
v-model:value="item.time" :options="timeOptions" class="flex-1 !bg-transparent" size="small"
|
||||
v-model:value="item.QuestionTime" :options="timeOptions" class="flex-1 !bg-transparent" size="small"
|
||||
:bordered="false"
|
||||
/>
|
||||
<span class="text-xs text-gray-400 font-bold">S</span>
|
||||
@ -334,7 +336,7 @@ defineExpose({ validate, reset })
|
||||
<icon-streamline-sharp:type-area-remix class="text-gray-400" />
|
||||
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">分数规则:</span>
|
||||
<NSelect
|
||||
v-model:value="item.scoreType" :options="scoreTypeOptions as unknown as SelectOption[]"
|
||||
v-model:value="item.QuestionRule" :options="scoreTypeOptions as unknown as SelectOption[]"
|
||||
class="flex-1 !bg-transparent" size="small" :bordered="false"
|
||||
/>
|
||||
</div>
|
||||
@ -344,7 +346,7 @@ defineExpose({ validate, reset })
|
||||
<icon-ic-round-star-border class="text-gray-400" />
|
||||
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">分数:</span>
|
||||
<NInputNumber
|
||||
v-model:value="item.score" :min="0" :show-button="false" class="flex-1 !bg-transparent"
|
||||
v-model:value="item.Point" :min="0" :show-button="false" class="flex-1 !bg-transparent"
|
||||
size="small" :bordered="false" placeholder="0"
|
||||
/>
|
||||
<span class="text-xs text-gray-400 font-bold">分</span>
|
||||
@ -374,9 +376,6 @@ defineExpose({ validate, reset })
|
||||
</div>
|
||||
|
||||
<NForm size="large">
|
||||
<NFormItem label="环节/题包名称">
|
||||
<NInput v-model:value="newRoundForm.name" placeholder="例如:第一轮 星·诗词大会" />
|
||||
</NFormItem>
|
||||
<NFormItem label="环节/题包类型">
|
||||
<NSelect
|
||||
v-model:value="newRoundForm.roundType" :options="roundTypeOptions as unknown as SelectOption[]"
|
||||
|
||||
@ -3,12 +3,15 @@ import { ref, watch } from 'vue'
|
||||
|
||||
export interface BasicInfoModel {
|
||||
name: string
|
||||
subTitle?: string
|
||||
startTime: string | null
|
||||
endTime: string | null
|
||||
groupCount: number
|
||||
teamCount: number
|
||||
poster: string
|
||||
roomId: number | null
|
||||
activityContent: string
|
||||
extraTimeContent: string
|
||||
}
|
||||
|
||||
export function useBasicInfo(props: any, emit: any) {
|
||||
@ -17,12 +20,15 @@ export function useBasicInfo(props: any, emit: any) {
|
||||
watch(() => props.modelValue, (val) => {
|
||||
const newForm: BasicInfoModel = {
|
||||
name: val.name,
|
||||
subTitle: val.subTitle,
|
||||
startTime: val.startTime,
|
||||
endTime: val.endTime,
|
||||
groupCount: val.groupCount,
|
||||
teamCount: val.teamCount,
|
||||
poster: val.poster,
|
||||
roomId: val.roomId,
|
||||
activityContent: val.activityContent,
|
||||
extraTimeContent: val.extraTimeContent,
|
||||
}
|
||||
|
||||
if (JSON.stringify(newForm) !== JSON.stringify(formData.value)) {
|
||||
@ -80,6 +86,14 @@ export function useBasicInfo(props: any, emit: any) {
|
||||
window.$message?.error('每组队伍数量必须为正整数')
|
||||
return false
|
||||
}
|
||||
if (!formData.value.activityContent || formData.value.activityContent === '<p><br></p>') {
|
||||
window.$message?.error('请输入普通赛事规则')
|
||||
return false
|
||||
}
|
||||
if (!formData.value.extraTimeContent || formData.value.extraTimeContent === '<p><br></p>') {
|
||||
window.$message?.error('请输入加时赛规则')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@ -87,12 +101,15 @@ export function useBasicInfo(props: any, emit: any) {
|
||||
function reset() {
|
||||
formData.value = {
|
||||
name: '阅读之星年度总决赛',
|
||||
subTitle: '',
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
groupCount: 10,
|
||||
teamCount: 4,
|
||||
poster: '',
|
||||
roomId: null,
|
||||
activityContent: '',
|
||||
extraTimeContent: '',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import { exportToExcel, readExcel } from '@sa/utils'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
export function useGroupManagement(props: any) {
|
||||
const groups = ref<Array<{ name: string, id: string, teams: Array<{ name: string }> }>>([])
|
||||
const groups = ref<Array<{ name: string, id: string, teams: Array<{ name: string, headImg: string }> }>>([])
|
||||
|
||||
// 根据分组数量和队伍数量生成分组数据
|
||||
function generateGroups() {
|
||||
@ -13,33 +13,48 @@ export function useGroupManagement(props: any) {
|
||||
if (!groupCount || groupCount <= 0)
|
||||
return
|
||||
|
||||
// 优先使用现有的 groups.value,如果为空则尝试使用 props 中的初始数据
|
||||
const oldGroups = groups.value.length > 0
|
||||
? groups.value
|
||||
: (props.modelValue?.groupManagement || [])
|
||||
// 获取当前已有的分组数据(优先用 groups.value,其次用 props 中的初始数据)
|
||||
const currentGroups = groups.value.length > 0 ? groups.value : (props.modelValue?.groupManagement || [])
|
||||
|
||||
groups.value = Array.from({ length: groupCount }).map((_, index) => {
|
||||
const i = index + 1
|
||||
const idStr = String(i).padStart(2, '0')
|
||||
const existingGroup = oldGroups[index]
|
||||
// 如果当前分组数量和配置一致,且每个分组的队伍数量也一致,则无需重新生成
|
||||
// 这能有效防止因引用变化导致的无限循环
|
||||
const isCountMatch = currentGroups.length === groupCount
|
||||
const isTeamCountMatch = currentGroups.every((g: any) => g.teams && g.teams.length === teamCount)
|
||||
|
||||
// 复用现有名称或生成默认名称
|
||||
const groupName = existingGroup ? existingGroup.name : `第${i}组`
|
||||
|
||||
// 生成或复用队伍数据
|
||||
const teams = Array.from({ length: teamCount }).map((__, tIndex) => {
|
||||
const existingTeam = existingGroup?.teams?.[tIndex]
|
||||
return {
|
||||
name: existingTeam ? existingTeam.name : '',
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
name: groupName,
|
||||
id: `G${idStr}`,
|
||||
teams,
|
||||
if (isCountMatch && isTeamCountMatch && currentGroups.length > 0) {
|
||||
// 即使数量匹配,如果 groups.value 为空(初始化时),还是需要赋值一次
|
||||
if (groups.value.length === 0) {
|
||||
groups.value = JSON.parse(JSON.stringify(currentGroups))
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 增量更新逻辑:保留已有数据,仅裁剪或新增
|
||||
const newGroups = []
|
||||
|
||||
for (let i = 0; i < groupCount; i++) {
|
||||
const existingGroup = currentGroups[i]
|
||||
const groupName = existingGroup ? existingGroup.name : `第${i + 1}组`
|
||||
const groupId = `G${String(i + 1).padStart(2, '0')}`
|
||||
|
||||
// 处理队伍
|
||||
const newTeams = []
|
||||
for (let t = 0; t < teamCount; t++) {
|
||||
const existingTeam = existingGroup?.teams?.[t]
|
||||
newTeams.push({
|
||||
name: existingTeam ? existingTeam.name : '',
|
||||
headImg: existingTeam?.headImg || '',
|
||||
})
|
||||
}
|
||||
|
||||
newGroups.push({
|
||||
name: groupName,
|
||||
id: groupId,
|
||||
teams: newTeams,
|
||||
})
|
||||
}
|
||||
|
||||
groups.value = newGroups
|
||||
}
|
||||
|
||||
watch(() => [props.config?.groupCount, props.config?.teamCount], generateGroups, { immediate: true, deep: true })
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
import { computed } from 'vue'
|
||||
import { roundTypeOptions, scoreTypeOptions } from '@/constants/business'
|
||||
import { computed, nextTick } from 'vue'
|
||||
import { useDict } from '@/hooks/business/useDict'
|
||||
|
||||
export interface QuestionItem {
|
||||
const { options: roundTypeOptions } = useDict('round_type')
|
||||
const { options: scoreTypeOptions } = useDict('score_type')
|
||||
|
||||
export interface QuestionItem extends Partial<Api.Competition.QuestionListRecord> {
|
||||
id: string
|
||||
questionId: string | null
|
||||
score: number
|
||||
time: number
|
||||
title: string
|
||||
scoreType: Api.Competition.QuestionScoreType
|
||||
uiType?: string
|
||||
templateId?: number
|
||||
}
|
||||
|
||||
export interface RoundModel {
|
||||
@ -28,7 +24,7 @@ export function useQuestionConfig(props: any) {
|
||||
const round = props.modelValue?.rounds?.[roundIndex]
|
||||
if (!round)
|
||||
return 0
|
||||
return round.questions.reduce((sum: number, item: any) => sum + (Number(item.score) || 0), 0)
|
||||
return round.questions.reduce((sum: number, item: any) => sum + (Number(item.Point) || 0), 0)
|
||||
}
|
||||
|
||||
// 计算每个轮次的时长
|
||||
@ -36,7 +32,7 @@ export function useQuestionConfig(props: any) {
|
||||
const round = props.modelValue?.rounds?.[roundIndex]
|
||||
if (!round)
|
||||
return 0
|
||||
return round.questions.reduce((sum: number, item: any) => sum + (Number(item.time) || 0), 0)
|
||||
return round.questions.reduce((sum: number, item: any) => sum + (Number(item.QuestionTime) || 0), 0)
|
||||
}
|
||||
|
||||
// 总计统计
|
||||
@ -44,43 +40,157 @@ export function useQuestionConfig(props: any) {
|
||||
const rounds = props.modelValue?.rounds || []
|
||||
return rounds.reduce((acc: any, round: any) => {
|
||||
acc.count += round.questions.length
|
||||
acc.score += round.questions.reduce((sum: number, item: any) => sum + (Number(item.score) || 0), 0)
|
||||
acc.time += round.questions.reduce((sum: number, item: any) => sum + (Number(item.time) || 0), 0)
|
||||
acc.score += round.questions.reduce((sum: number, item: any) => sum + (Number(item.Point) || 0), 0)
|
||||
acc.time += round.questions.reduce((sum: number, item: any) => sum + (Number(item.QuestionTime) || 0), 0)
|
||||
return acc
|
||||
}, { count: 0, score: 0, time: 0 })
|
||||
})
|
||||
|
||||
// 新增轮次
|
||||
// 拖拽结束处理:根据拖动意图重新分组
|
||||
function handleDragEnd(roundIndex: number, event: any) {
|
||||
nextTick(() => {
|
||||
const round = props.modelValue.rounds[roundIndex]
|
||||
if (!round || !round.questions)
|
||||
return
|
||||
|
||||
const { newIndex } = event
|
||||
const questions = round.questions
|
||||
const draggedItem = questions[newIndex]
|
||||
|
||||
if (!draggedItem)
|
||||
return
|
||||
|
||||
// 统一转字符串进行比较,兼容 number/string
|
||||
const targetQid = String(draggedItem.QuestionID)
|
||||
|
||||
// 1. 分离目标组和其他元素
|
||||
const targetItems = questions.filter((q: any) => String(q.QuestionID) === targetQid)
|
||||
const nonTargetItems = questions.filter((q: any) => String(q.QuestionID) !== targetQid)
|
||||
|
||||
// 2. 计算插入位置
|
||||
// 我们需要找到拖拽元素在非目标元素中的“相对位置”
|
||||
// 如果它被拖到了同组元素中间,我们应该将其归并到该组的后面(保持组的完整性)
|
||||
|
||||
let prevNonTargetIndex = -1
|
||||
|
||||
// 从 newIndex 向前寻找第一个非目标元素
|
||||
for (let i = newIndex - 1; i >= 0; i--) {
|
||||
if (String(questions[i].QuestionID) !== targetQid) {
|
||||
// 找到这个非目标元素在 nonTargetItems 中的索引
|
||||
const prevItem = questions[i]
|
||||
prevNonTargetIndex = nonTargetItems.indexOf(prevItem)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let insertionIndex = 0
|
||||
|
||||
if (prevNonTargetIndex === -1) {
|
||||
// 如果前面没有非目标元素,说明插到了最前面
|
||||
insertionIndex = 0
|
||||
}
|
||||
else {
|
||||
// 如果前面有非目标元素,检查它所属的组
|
||||
const prevItem = nonTargetItems[prevNonTargetIndex]
|
||||
const prevGroupId = String(prevItem.QuestionID)
|
||||
|
||||
// 找到该组在 nonTargetItems 中的最后一个元素的位置
|
||||
// 这样做是为了确保如果插入到了某组中间,会跳过该组剩余元素,插在该组之后
|
||||
let lastIndexInGroup = prevNonTargetIndex
|
||||
for (let i = prevNonTargetIndex + 1; i < nonTargetItems.length; i++) {
|
||||
if (String(nonTargetItems[i].QuestionID) === prevGroupId) {
|
||||
lastIndexInGroup = i
|
||||
}
|
||||
else {
|
||||
// 遇到不同组,停止
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
insertionIndex = lastIndexInGroup + 1
|
||||
}
|
||||
|
||||
// 3. 重组数组
|
||||
round.questions = [
|
||||
...nonTargetItems.slice(0, insertionIndex),
|
||||
...targetItems,
|
||||
...nonTargetItems.slice(insertionIndex),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// 题目类型变更处理:按首次出现顺序分组
|
||||
function handleTypeChange(roundIndex: number) {
|
||||
const round = props.modelValue.rounds[roundIndex]
|
||||
if (!round || !round.questions)
|
||||
return
|
||||
|
||||
const questions = round.questions
|
||||
const groups = new Map<string, any[]>()
|
||||
const groupOrder: string[] = []
|
||||
|
||||
// 记录分组和顺序
|
||||
for (const q of questions) {
|
||||
const qid = String(q.QuestionID)
|
||||
if (!groups.has(qid)) {
|
||||
groups.set(qid, [])
|
||||
groupOrder.push(qid)
|
||||
}
|
||||
groups.get(qid)?.push(q)
|
||||
}
|
||||
|
||||
// 重组
|
||||
const newQuestions: any[] = []
|
||||
for (const qid of groupOrder) {
|
||||
const group = groups.get(qid)
|
||||
if (group) {
|
||||
newQuestions.push(...group)
|
||||
}
|
||||
}
|
||||
|
||||
round.questions = newQuestions
|
||||
}
|
||||
|
||||
// 新增轮次(现在逻辑为:根据 roundType 添加题目到对应分组,如果分组不存在则创建)
|
||||
function addRound(customName?: string, customCount?: number, roundType?: Api.Competition.CompetitionRoundType) {
|
||||
if (!props.modelValue.rounds) {
|
||||
props.modelValue.rounds = []
|
||||
}
|
||||
const index = props.modelValue.rounds.length + 1
|
||||
const count = typeof customCount === 'number' ? customCount : 10
|
||||
const name = typeof customName === 'string' ? customName : `第${index}轮:比赛环节`
|
||||
|
||||
props.modelValue.rounds.push({
|
||||
id: `round_${Date.now()}`,
|
||||
name,
|
||||
roundType: roundType || roundTypeOptions[0].value,
|
||||
questions: Array.from({ length: count }).map(() => ({
|
||||
id: generateId(),
|
||||
questionId: null,
|
||||
score: 5,
|
||||
time: 30,
|
||||
title: '',
|
||||
scoreType: scoreTypeOptions[0].value,
|
||||
})),
|
||||
})
|
||||
const type = roundType || roundTypeOptions.value?.[0]?.value || 0
|
||||
const count = typeof customCount === 'number' ? customCount : 10
|
||||
|
||||
// 查找是否已存在该类型的轮次
|
||||
const existingRound = props.modelValue.rounds.find((r: any) => r.roundType === type)
|
||||
|
||||
const newQuestions = Array.from({ length: count }).map(() => ({
|
||||
id: generateId(),
|
||||
QuestionID: null,
|
||||
Point: 5,
|
||||
QuestionTime: 30,
|
||||
ActitvityQuestionName: '',
|
||||
QuestionRule: scoreTypeOptions.value?.[0]?.value || 0,
|
||||
}))
|
||||
|
||||
if (existingRound) {
|
||||
existingRound.questions.push(...newQuestions)
|
||||
handleTypeChange(props.modelValue.rounds.indexOf(existingRound))
|
||||
}
|
||||
else {
|
||||
// 获取类型名称作为默认名称
|
||||
const typeLabel = roundTypeOptions.value?.find(opt => opt.value === type)?.label || '未知类型'
|
||||
props.modelValue.rounds.push({
|
||||
id: `round_${Date.now()}`,
|
||||
name: typeLabel,
|
||||
roundType: type,
|
||||
questions: newQuestions, // 新创建时全是空的,不需要排序,或者排一下也无妨
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 删除轮次
|
||||
function removeRound(index: number) {
|
||||
props.modelValue.rounds.splice(index, 1)
|
||||
// 重命名剩余轮次
|
||||
props.modelValue.rounds.forEach((round: RoundModel, idx: number) => {
|
||||
round.name = round.name.replace(/第\d+轮/, `第${idx + 1}轮`)
|
||||
})
|
||||
}
|
||||
|
||||
// 新增题目
|
||||
@ -89,12 +199,13 @@ export function useQuestionConfig(props: any) {
|
||||
if (round) {
|
||||
round.questions.push({
|
||||
id: generateId(),
|
||||
value: null,
|
||||
score: 5,
|
||||
time: 30,
|
||||
title: '',
|
||||
scoreType: scoreTypeOptions[0].value,
|
||||
QuestionID: 0,
|
||||
Point: 5,
|
||||
QuestionTime: 30,
|
||||
ActitvityQuestionName: '',
|
||||
QuestionRule: 1,
|
||||
})
|
||||
handleTypeChange(roundIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@ -123,31 +234,31 @@ export function useQuestionConfig(props: any) {
|
||||
const question = round.questions[j]
|
||||
const questionPrefix = `${round.name} 第 ${j + 1} 题`
|
||||
|
||||
if (!question.questionId) {
|
||||
if (question.QuestionID === undefined || question.QuestionID === null || question.QuestionID === '') {
|
||||
window.$message?.error(`${questionPrefix}未选择题型`)
|
||||
return false
|
||||
}
|
||||
if (!question.uiType) {
|
||||
if (question.UIType === undefined || question.UIType === null || question.UIType === '') {
|
||||
window.$message?.error(`${questionPrefix}未选择UI类型`)
|
||||
return false
|
||||
}
|
||||
if (!question.templateId) {
|
||||
if (question.TemplateID === undefined || question.TemplateID === null || question.TemplateID === '') {
|
||||
window.$message?.error(`${questionPrefix}未选择模板`)
|
||||
return false
|
||||
}
|
||||
if (!question.time) {
|
||||
if (question.QuestionTime === undefined || question.QuestionTime === null || question.QuestionTime === '') {
|
||||
window.$message?.error(`${questionPrefix}未设置答题时间`)
|
||||
return false
|
||||
}
|
||||
if (!question.scoreType) {
|
||||
if (question.QuestionRule === undefined || question.QuestionRule === null || question.QuestionRule === '') {
|
||||
window.$message?.error(`${questionPrefix}未设置分数规则`)
|
||||
return false
|
||||
}
|
||||
if (!question.title) {
|
||||
if (question.ActitvityQuestionName === undefined || question.ActitvityQuestionName === null || question.ActitvityQuestionName === '') {
|
||||
window.$message?.error(`${questionPrefix}未填写标题`)
|
||||
return false
|
||||
}
|
||||
if (question.score === null || question.score === undefined) {
|
||||
if (question.Point === null || question.Point === undefined) {
|
||||
window.$message?.error(`${questionPrefix}未设置分数`)
|
||||
return false
|
||||
}
|
||||
@ -169,6 +280,8 @@ export function useQuestionConfig(props: any) {
|
||||
addRound,
|
||||
removeRound,
|
||||
addQuestion,
|
||||
handleDragEnd,
|
||||
handleTypeChange,
|
||||
removeQuestion,
|
||||
validate,
|
||||
reset,
|
||||
|
||||
Reference in New Issue
Block a user