feat(admin): 新增品牌logo资源并优化用户界面组件

- 新增重庆、树人、文字logo资源(PNG和WebP格式)
- 新增用户界面背景图片资源(内容背景、对框、主页背景等)
- 优化ActionButton组件样式(自适应宽度、禁用状态样式)
- 更新CompetitionLayout组件header布局,集成新logo资源
- 优化TianZiGe组件功能和交互体验
- 更新竞赛相关页面组件(QuestionConfig、draw、QuestionRenderer等)
- 更新排名列表页面样式和功能
- 更新诗词常识题库数据
- 完善Competition类型定义
This commit is contained in:
2026-03-18 16:58:13 +08:00
parent 02b4720b34
commit 2fad025496
25 changed files with 256 additions and 193 deletions

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { NButton, NForm, NFormItem, NInputNumber, NModal, NSelect, type SelectOption } from 'naive-ui'
import { onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
import { useDict } from '@/hooks/business/useDict'
import { fetchGetQuestionListAll } from '@/service/api/question'
@ -29,7 +29,10 @@ const props = defineProps<{
const { options: roundTypeOptions } = useDict('round_type')
const { options: scoreTypeOptions } = useDict('score_type')
const { options: timeOptions } = useDict('time_out')
const { options: timeOptionsRaw } = useDict('time_out')
const timeOptions = computed(() =>
[...timeOptionsRaw.value].sort((a, b) => Number(a.value) - Number(b.value)),
)
// const { options: uiTypeOptions } = useDict('ui_type', 'string')
const { options: uiTypeOptions } = useDict('question_category_name', 'string')

View File

@ -23,7 +23,6 @@ function handleAdd() {
}
async function handleCopy(item: Api.Competition.CompetitionItem) {
// TODO: 实现复制逻辑
try {
const { error } = await fetchCopyActivity(item.Id)
if (error) {
@ -163,7 +162,3 @@ onMounted(() => fetchActivityList())
<CompetitionAddModal v-model:show="showAddModal" @success="fetchActivityList" />
</NSpace>
</template>
<style scoped>
/* 针对不同屏幕微调间距 */
</style>

View File

@ -151,7 +151,7 @@ function edit(item: any) {
<template #header>
<div class="flex items-center justify-between">
<div class="mid-title">
<div class="text-16px font-bold">
<div class="text-2xl font-bold">
{{ rankTitle }}
</div>
<div class="mt-4px text-12px text-gray-500">

View File

@ -241,9 +241,9 @@ async function fetchQuestions() {
}
/**
* 获取当前正在比赛的题目
* 获取当前正在比赛的题目(不触发同步选中逻辑)
*/
async function fetchCurrentQuestion() {
async function fetchCurrentQuestion(shouldSync = true) {
if (!roomId.value)
return
try {
@ -254,8 +254,10 @@ async function fetchCurrentQuestion() {
}
currentQuestion.value = data?.data || {}
console.warn('current question', currentQuestion.value)
// 拉取当前题目信息后尝试同步选中题
syncCurrentQuestionSelection()
// 只在初始化时同步选中题,轮询时不同步
if (shouldSync) {
syncCurrentQuestionSelection()
}
}
catch (error: any) {
message.error(error.message || '获取当前题目失败')
@ -467,8 +469,8 @@ async function handleNexCurrentQuestion() {
function handlePublish() {
const endTimeStr = (currentQuestion.value as any)?.EndTime
if (endTimeStr && Date.now() < new Date(endTimeStr).getTime()) {
window.$message?.warning('倒计时未结束,暂不能发布')
if (endTimeStr && Date.now() < new Date(endTimeStr).getTime() + 5000) {
window.$message?.warning('倒计时未结束需等待5秒延迟,暂不能发布')
return
}
if (currentQuestion.value?.TeamGroupQuestionID) {
@ -583,7 +585,8 @@ const canPublish = computed(() => {
const endTime = (currentQuestion.value as any)?.EndTime
if (!endTime)
return true
const end = new Date(endTime).getTime()
// 结束时间 + 5秒延迟才能发布
const end = new Date(endTime).getTime() + 5000
return nowTs.value >= end
})
@ -593,7 +596,7 @@ function startPolling() {
// 检查是否已经结束
const endTime = (currentQuestion.value as any)?.EndTime
if (endTime) {
// 增加 8s 缓冲时间
// 增加 12s 缓冲时间
const end = new Date(endTime).getTime() + 12000
const now = new Date().getTime()
if (now > end) {
@ -616,7 +619,7 @@ function startPolling() {
// 每次轮询前再次检查是否过期
const currentEndTime = (currentQuestion.value as any)?.EndTime
if (currentEndTime) {
// 增加 8s 缓冲时间
// 增加 12s 缓冲时间
const endTs = new Date(currentEndTime).getTime() + 12000
if (Date.now() > endTs) {
console.log('题目时间已到含12s缓冲停止轮询', currentEndTime)
@ -625,6 +628,9 @@ function startPolling() {
}
}
nowTs.value = Date.now()
// 轮询时同时更新当前题目信息(获取最新的 EndTime但不触发同步
fetchCurrentQuestion(false)
fetchAnswerData()
}, 3000)
}
@ -681,7 +687,7 @@ watch(() => currentQuestionDetailID.value, (newVal) => {
onMounted(async () => {
// 并行拉取分组、题目与当前题目,完成后做一次同步
await Promise.all([fetchGroups(), fetchQuestions(), fetchCurrentQuestion()])
await Promise.all([fetchGroups(), fetchQuestions(), fetchCurrentQuestion(true)])
syncCurrentQuestionSelection()
startPolling()
})

View File

@ -144,7 +144,7 @@ const titleBgTypeNum = computed(() => RoundType.value === 0 ? 3 : 4)
>
<div class="h-full w-full flex flex-col items-center pt-10 font-sans">
<div v-if="question" class="mb-10 max-w-4xl text-center">
<div class="text-2xl text-gray-800 font-medium leading-relaxed tracking-wide">
<div class="text-4xl text-gray-800 font-medium leading-relaxed tracking-wide">
{{ questionTitle || '' }}
</div>
</div>

View File

@ -1,77 +1,65 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import TianZiGe from '@/components/custom/user/TianZiGe.vue'
import { QuestionCategoryEnum } from '@/enum/business'
import { useCompetitionStore } from '@/store/modules/competition'
const store = useCompetitionStore()
const currentQuestionMainInfo = computed(() => store.currentQuestionMainInfo)
const currentQuestionDetail = computed(() => store.currentQuestionDetail)
// console.log(currentQuestionMainInfo.value, 'currentQuestionMainInfo')
// 题目
const title = computed(() => currentQuestionMainInfo.value?.QuestionList?.QuestionContent)
// 题目内容
const content = computed(() => currentQuestionDetail.value.Name)
// 图片
const ImageUrl = computed(() => currentQuestionDetail.value.ImageUrl)
// 模版类型
const template = computed(() => currentQuestionMainInfo.value?.Activity_Question?.UIType)
/**
* 组合后的标题(用于汉字加一加模板)
* 将 content 插入到 title 的引号中,并拆分以支持不同样式
*/
const parsedComponentAddTitle = computed(() => {
const t = title.value || ''
const c = content.value || ''
// 尝试替换全角空引号
if (t.includes('“”')) {
const [prefix, suffix] = t.split('“”')
return {
type: 'quote_full',
prefix,
content: c,
suffix,
}
}
// 尝试替换半角空引号
if (t.includes('""')) {
const [prefix, suffix] = t.split('""')
return {
type: 'quote_half',
prefix,
content: c,
suffix,
}
}
// 如果没有找到空引号,直接追加
return {
type: 'normal',
prefix: t,
content: c,
suffix: '',
}
})
/** 获取富文本里面的text */
const pinyinChars = computed(() => {
// 汉字听写1按 # 分割内容
const dictationParts = computed(() => {
if (content.value) {
// 创建临时元素提取HTML文本内容
const div = document.createElement('div')
div.innerHTML = content.value
const text = div.textContent || ''
return text.trim().split(/\s+/).filter(Boolean)
return content.value.split('#').filter(Boolean)
}
return []
})
// 联想对对碰:上方词组
const associationWords = computed(() => {
const name = currentQuestionDetail.value?.Name || ''
return name.split(/[,,、]/).filter(Boolean)
})
// 联想对对碰:答案按逗号分行,只保留汉字
const answerLines = computed(() => {
const answer = currentQuestionDetail.value?.Answer || ''
return answer.split(/[,]/)
.map(line => line.split('').filter(char => /[\u4E00-\u9FA5]/.test(char)))
.filter(line => line.length > 0)
})
// 联想对对碰:关键词索引和文字
const keywordInfo = computed(() => ({
index: currentQuestionDetail.value?.KeyWordsIndex ?? -1,
word: currentQuestionDetail.value?.KeyWord || '',
}))
// 计算全局字符索引(用于匹配 KeyWordsIndex
function getGlobalCharIndex(lineIndex: number, charIndex: number) {
let globalIndex = 0
for (let i = 0; i < lineIndex; i++) {
globalIndex += answerLines.value[i].length
}
return globalIndex + charIndex
}
watch(
() => [currentQuestionMainInfo, currentQuestionDetail],
() => {
// eslint-disable-next-line no-console
console.log('Current currentQuestionMainInfo:', currentQuestionMainInfo.value)
// eslint-disable-next-line no-console
console.log('Current currentQuestionMainInfo:', currentQuestionMainInfo)
console.log('Current currentQuestionDetail:', currentQuestionDetail.value)
},
{ deep: true, immediate: true },
)
@ -81,64 +69,59 @@ watch(
<div class="w-full flex p-6">
<!-- <p>template:{{ template }}</p> -->
<!-- 模板A: 汉字听写1-提示 (拼音+提示) -->
<div v-if="template === QuestionCategoryEnum.ChineseCharacterDictation1 || template === QuestionCategoryEnum.ChineseCharacterDictation2" class="text-center">
<div v-if="template === QuestionCategoryEnum.ChineseCharacterDictation1" class="text-center">
<div class="mb-6 inline-block text-5xl font-bold leading-none">
<div class="ptions-container1 mb-2 text-center text-2xl">
{{ title }}
<div class="flex items-center justify-center gap-4">
<div
v-for="(part, index) in dictationParts" :key="`part-${index}`"
class="text-4xl"
:class="index === 0 ? 'short-content-bg' : 'content-with-bg'"
>
{{ part }}
</div>
</div>
<div class="rich-content ml-3 mt-4 flex gap-1 color-red-600">
<!-- <TianZiGe v-for="(char, index) in pinyinChars" :key="index" :char="char" /> -->
</div>
</div>
<!-- 模板D: 汉字听写 2 词语听写 -->
<div
v-else-if="template === QuestionCategoryEnum.ChineseCharacterDictation2 || template === QuestionCategoryEnum.WordDictation"
class="flex flex-col items-center"
>
<div class="mb-1 text-3xl text-gray-800 font-bold leading-relaxed">
<div class="rich-content content-with-bg ml-3 mt-4 flex text-6xl !px-16 !py-10 !tracking-[0.1em]">
{{ content }}
</div>
</div>
</div>
<!-- 模板B: 汉字听写-同音字 (大字) -->
<!-- 暂时未匹配到枚举保持原样或删除 -->
<!-- <div v-else-if="template === 'TEMPLATE_DICTATION_HOMOPHONE'" class="text-center"> -->
<!-- 模板C: 汉字加一加 (提示+部件) -->
<div v-else-if="template === QuestionCategoryEnum.CharacterRadicalAddition" class="text-center">
<div class="text-[50px] font-bold">
<template v-if="parsedComponentAddTitle.type === 'quote_full'">
<span class="text-black">{{ parsedComponentAddTitle.prefix }}</span>
<span class="text-red-600">{{ parsedComponentAddTitle.content }}</span>
<span class="text-black">{{ parsedComponentAddTitle.suffix }}</span>
</template>
<template v-else-if="parsedComponentAddTitle.type === 'quote_half'">
<span class="text-black">{{ parsedComponentAddTitle.prefix }}"</span>
<span class="text-red-600">{{ parsedComponentAddTitle.content }}</span>
<span class="text-black">"{{ parsedComponentAddTitle.suffix }}</span>
</template>
<template v-else>
<span class="text-black">{{ parsedComponentAddTitle.prefix }}</span>
<span class="ml-4 text-red-600">{{ parsedComponentAddTitle.content }}</span>
</template>
<div class="mb-6 inline-block text-5xl font-bold leading-none">
<div class="rich-content ml-3 mt-4 flex gap-1 text-5xl tracking-[0.1em]">
请写出含有 <span class="text-color-red !text-6xl">{{ content }}</span>的汉字书写时间为60秒
</div>
</div>
</div>
<!-- 模板D: 词语听写 (拼音+提示) -->
<div v-else-if="template === QuestionCategoryEnum.WordDictation" class="flex flex-col items-center">
<div class="mb-8 flex flex-wrap justify-center gap-6">
<TianZiGe v-for="(char, index) in pinyinChars" :key="index" :char="char" />
<!-- 模板D: 成语写一写1 (拼音+提示) -->
<div v-else-if="template === QuestionCategoryEnum.IdiomWriting1" class="flex flex-col items-center">
<div class="mb-1 text-gray-800 font-bold leading-relaxed">
<div class="rich-content ml-3 mt-4 flex gap-1 text-4xl">
{{ content }}
</div>
</div>
</div>
<!-- 模板E: 成语-文字要求 -->
<div v-else-if="template === QuestionCategoryEnum.IdiomWriting1 || template === QuestionCategoryEnum.IdiomWriting2" class="text-center">
<div v-else-if="template === QuestionCategoryEnum.IdiomWriting2" class="text-center">
<div class="mb-8 text-4xl text-gray-800 font-bold">
<!-- {{ content }} -->
<div class="rich-content" v-html="content" />
<NImage :src="ImageUrl" preview-disabled width="300" object-fit="contain" />
</div>
</div>
<!-- 模板G: 诗词理解-选择题 (通用选择题模板) -->
<div v-else-if="template === QuestionCategoryEnum.PoetryComprehension" class="w-full flex flex-col items-center">
<div class="options-container text-2xl">
{{ title }}
</div>
<div class="mb-1 text-3xl text-gray-800 font-bold leading-relaxed">
<div class="rich-content" v-html="content" />
</div>
@ -146,57 +129,65 @@ watch(
<!-- 模板ChangeWords: 换字组成语 -->
<div v-else-if="template === QuestionCategoryEnum.ChangeWords" class="w-full flex flex-col items-center">
<div class="options-container text-2xl">
{{ title }}
</div>
<div class="mb-1 text-3xl text-gray-800 font-bold leading-relaxed">
<div class="rich-content" v-html="content" />
<div class="content-with-bg mb-1 mt-10 p-6 text-6xl text-gray-800 font-bold !px-16 !py-8">
{{ content }}
</div>
</div>
<!-- 模板: 场景猜诗句 -->
<div v-else-if="template === QuestionCategoryEnum.SceneBasedPoemGuessing" class="w-full flex flex-col items-center">
<div class="options-container text-2xl">
{{ title }}
</div>
<div class="mb-1 text-3xl text-gray-800 font-bold leading-relaxed">
<div class="mb-1 p-16 text-3xl text-gray-800 font-bold leading-relaxed line-height-20">
<div class="rich-content" v-html="content" />
</div>
</div>
<!-- 模板: 联想对对碰 -->
<div v-else-if="template === QuestionCategoryEnum.AssociationMatching" class="w-full flex flex-col items-center">
<div class="options-container text-2xl">
{{ title }}
<div
v-else-if="template === QuestionCategoryEnum.AssociationMatching"
class="w-full flex flex-col items-center gap-8"
>
<!-- 上方词组 -->
<div class="flex flex-wrap justify-center gap-4">
<div v-for="(word, index) in associationWords" :key="`word-${index}`" class="association-word text-bold">
{{ word }}
</div>
</div>
<div class="mb-1 text-3xl text-gray-800 font-bold leading-relaxed">
<div class="rich-content" v-html="content" />
<!-- 下方答案字符按逗号分行 -->
<div class="flex flex-col items-center gap-3">
<div v-for="(line, lineIndex) in answerLines" :key="`line-${lineIndex}`" class="flex gap-4">
<div
v-for="(char, charIndex) in line" :key="`char-${lineIndex}-${charIndex}`"
class="association-char text-5xl"
>
<span v-if="getGlobalCharIndex(lineIndex, charIndex) === keywordInfo.index">
{{ keywordInfo.word }}
</span>
</div>
</div>
</div>
</div>
<!-- 模板: 字词飞花令 -->
<div v-else-if="template === QuestionCategoryEnum.CharacterWordFlowerDrinkingGame" class="w-full flex flex-col items-center">
<div class="options-container text-2xl">
<div
v-else-if="template === QuestionCategoryEnum.CharacterWordFlowerDrinkingGame"
class="w-full flex flex-col items-center"
>
<div class="options-container text-5xl">
请回答带有{{ content }}的诗词
</div>
</div>
<!-- 模板: 诗词常识 -->
<div v-else-if="template === QuestionCategoryEnum.PoetryCommonKnowledge" class="w-full flex flex-col items-center">
<div class="options-container text-2xl">
{{ title }}
</div>
<div class="mb-1 text-3xl text-gray-800 font-bold leading-relaxed">
<div class="mb-1 p-16 text-3xl text-gray-800 font-bold leading-relaxed line-height-20">
<div class="rich-content" v-html="content" />
</div>
</div>
<!-- 模板: 逆向接诗句 -->
<div v-else-if="template === QuestionCategoryEnum.ReversePoemMatching" class="w-full flex flex-col items-center">
<div class="options-container text-2xl">
{{ title }}
</div>
<div class="mb-1 text-3xl text-gray-800 font-bold leading-relaxed">
<div class="mb-1 mt-20 text-5xl text-gray-800 font-bold leading-relaxed">
<div class="rich-content" v-html="content" />
</div>
</div>
@ -206,7 +197,7 @@ watch(
<div class="ptions-container1 mb-2 text-center text-2xl">
{{ title }}
</div>
<div class="rich-content ml-3 mt-4 flex gap-1 color-red-600">
<div class="rich-content ml-3 mt-4 flex gap-1">
{{ content }}
</div>
</div>
@ -413,5 +404,53 @@ watch(
margin: 0 auto;
vertical-align: middle;
}
:deep(p),
:deep(span),
:deep(div) {
font-size: 2rem; /* text-5xl */
}
}
.content-with-bg {
background: url('@/assets/imgs/user/content-bg.png') no-repeat center;
background-size: 100% 100%;
border-radius: 12px;
padding: 20px 30px;
letter-spacing: 0.4em;
}
.short-content-bg {
background: url('@/assets/imgs/user/short-content-bg.png') no-repeat center;
background-size: 100% 100%;
border-radius: 12px;
padding: 20px 30px;
letter-spacing: 0.1em;
}
.association-word {
background: url('@/assets/imgs/user/dui-kuang.png') no-repeat center;
background-size: 100% 100%;
min-width: 160px;
height: 70px;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
font-weight: bold;
color: white;
padding: 0 20px;
}
.association-char {
background: url('@/assets/imgs/user/dui-text-bg.png') no-repeat center;
background-size: 100% 100%;
width: 100px;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
color: white;
}
</style>

View File

@ -83,13 +83,13 @@ async function getUserRankList() {
render(row: TeamData) {
const rank = row.rank
if (rank === 1) {
return <NTag type="warning" size="large" round>🏆</NTag>
return <NTag type="warning" size="large" class="!py-6 !text-3xl" round>🏆</NTag>
}
if (rank === 2) {
return <NTag type="info" size="large" round>🥈</NTag>
return <NTag type="info" size="large" class="!py-6 !text-3xl" round>🥈</NTag>
}
if (rank === 3) {
return <NTag type="success" size="large" round>🥉</NTag>
return <NTag type="success" size="large" class="!py-6 !text-3xl" round>🥉</NTag>
}
return <span>{rank}</span>
},
@ -140,14 +140,17 @@ function rowClassName(row: TeamData) {
<div class="leaderboard-container">
<div class="leaderboard-header">
<NImage :src="title" alt="logo" preview-disabled class="mt--60px" object-fit="contain" />
<p class="mt--20px text-center text-sm text-#6b778d">
<p class="mt--20px text-center text-lg text-#6b778d">
"勤学如春起之苗,不见其增,日有所长"
</p>
<ActionButton v-if="hasExtraTimeBtn" class="position-absolute bottom-20px right-20px" type="text" text="加时赛" @click="handleAddTime" />
<ActionButton
v-if="hasExtraTimeBtn" class="position-absolute bottom-20px right-20px" type="text" text="加时赛"
@click="handleAddTime"
/>
</div>
<div class="leaderboard-content">
<NDataTable
:columns="columns" :data="tableData" :bordered="false" :bottom-bordered="false"
:columns="columns" :data="tableData" :bordered="false" :bottom-bordered="false" size="large"
:row-class-name="rowClassName" class="rank-table"
/>
</div>