feat(admin): 优化大屏开发体验并完善用户界面组件

- 新增大屏开发调试指南文档,包含设计基准、调试方法和字体配置说明
- 添加字体大小预览工具,支持多分辨率视口切换(2048x1080/1920x1080/3840x2160)
- 新增字体资源 README 文档和团队标题背景图片
- 优化用户界面背景图片(title-bg3.png、title-bg4.png)
- 更新 ActionButton 和 CompetitionLayout 组件样式
- 完善用户模块各页面布局(分析、封面、抽签、游戏、规则、团队)
- 优化题目渲染器组件和成绩详情页面
- 更新 UnoCSS 配置以支持大屏字体缩放方案
This commit is contained in:
2026-03-20 17:05:23 +08:00
parent e061f46e66
commit 18e3f6f7f2
18 changed files with 645 additions and 106 deletions

View File

@ -32,6 +32,8 @@ const currentQuestion = ref<Api.Competition.GetCurrentQuestionInfo>() // 当前
const questionInfo = ref<Api.Competition.QuestionListDetailRound>() // 题目详情
const currentQuestionDetailID = ref<number | string>('') // 当前选中的题目详情 ID
const nowTs = ref(Date.now())
// 判断当前activityId是正在比赛的活动
const isActiveActivityId = computed(() => {
if (!currentQuestion.value)
@ -465,6 +467,9 @@ async function handleNexCurrentQuestion() {
currentQuestionId.value = currentQuestion.value?.QuestionID || currentQuestionId.value
currentGroupId.value = currentQuestion.value?.TeamGroupID || currentGroupId.value
}
// 更新当前时间戳,以便重新计算 canPublish
nowTs.value = Date.now()
}
function handlePublish() {
@ -580,7 +585,6 @@ function handlePublish() {
* 消费并发布当前题目
*/
const pollingTimer = ref<NodeJS.Timeout | null>(null)
const nowTs = ref(Date.now())
const canPublish = computed(() => {
const endTime = (currentQuestion.value as any)?.EndTime
if (!endTime)
@ -808,27 +812,29 @@ onUnmounted(() => {
</div>
</div>
<!-- 图片展示 -->
<div class="flex items-center justify-center">
<div class="relative min-h-30 flex items-center justify-center">
<NImage
:src="item.AnswerValuePicture || item.imageUrl" object-fit="contain"
class="max-h-[300px] w-full"
:src="item.AnswerValuePicture || item.imageUrl"
object-fit="contain"
class="pointer-events-auto max-h-[300px] w-full cursor-pointer"
show-toolbar-tooltip
/>
</div>
<!-- Toggle Button Overlay (Center) -->
<div
class="absolute inset-0 flex flex-col items-center justify-center gap-2 opacity-0 transition-opacity duration-200 hover:opacity-100"
class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-2 opacity-0 transition-opacity duration-200 hover:opacity-100"
>
<!-- 如果未校准显示两个按钮 -->
<template v-if="!item._isManual">
<NButton
strong secondary round size="medium" type="success" class="shadow-xl"
strong secondary round size="medium" type="success" class="pointer-events-auto shadow-xl"
@click="setStatus(team, item, 1)"
>
确认正确 (绿)
</NButton>
<NButton
strong secondary round size="medium" type="error" class="shadow-xl"
strong secondary round size="medium" type="error" class="pointer-events-auto shadow-xl"
@click="setStatus(team, item, 0)"
>
确认错误 ()
@ -839,7 +845,7 @@ onUnmounted(() => {
<template v-else>
<NButton
strong secondary round size="large" :type="item.Status === 1 ? 'error' : 'success'"
class="shadow-xl" @click="setStatus(team, item, item.Status === 1 ? 0 : 1)"
class="pointer-events-auto shadow-xl" @click="setStatus(team, item, item.Status === 1 ? 0 : 1)"
>
切换为 {{ item.Status === 1 ? '错误 (红)' : '正确 (绿)' }}
</NButton>
@ -891,8 +897,10 @@ onUnmounted(() => {
<div class="h-full flex items-center justify-center">
<NImage
v-if="item.AnswerValuePicture || item.imageUrl"
:src="item.AnswerValuePicture || item.imageUrl" object-fit="contain"
class="max-h-[800px] w-full"
:src="item.AnswerValuePicture || item.imageUrl"
object-fit="contain"
class="pointer-events-auto max-h-[800px] w-full cursor-pointer"
show-toolbar-tooltip
/>
<div v-else class="text-gray-300">
<div class="i-carbon-no-image text-6xl" />

View File

@ -127,7 +127,17 @@ async function fetchGetGameStatisticsData() {
QuestionDetaiID: Number(QuestionDetailID.value),
}
const { data } = await fetchGetGameStatistics(_params)
teams.value = (data?.data as any[]) || []
const rawTeams = (data?.data as any[]) || []
// 为每个队伍的图片 URL 添加时间戳,防止缓存
teams.value = rawTeams.map((team) => {
if (team.UserAnswerPicture) {
const timestamp = Date.now()
const separator = team.UserAnswerPicture.includes('?') ? '&' : '?'
team.UserAnswerPicture = `${team.UserAnswerPicture}${separator}_t=${timestamp}`
}
return team
})
}
catch (error: any) {
console.error('Failed to fetch game statistics', error)
@ -150,7 +160,7 @@ onBeforeUnmount(() => {
<template>
<CompetitionLayout
:show-title="true" :show-back="false" :show-next="showNextBtn" title="答题图解" action-position="top"
:show-title="true" :show-back="false" :show-next="showNextBtn" :title-bg-type="3" title-text-color="#ffffff" title="答题图解" action-position="top"
back-btn-text="返回" :next-btn-text="isCompleted ? '该组已完成回到选组' : '下一题'" btn-theme="light" @back="handleBack"
@next="handleNext"
>
@ -170,11 +180,12 @@ onBeforeUnmount(() => {
class="mb-3 flex items-center gap-2 rounded-full from-sky-300 to-cyan-200 bg-gradient-to-r px-4 py-2 shadow-sm"
>
<span class="text-lg text-white font-bold drop-shadow">{{ team.TeamName }}</span>
<span class="ml-2 rounded-full bg-white/40 px-2 py-0.5 text-sm">正确 <span class="text-md text-blue">{{
team.correctCount }}</span> </span>
<span class="text-s rounded-full bg-white/40 px-2 py-0.5">积分 <span class="text-md text-blue">{{
team.Points
}}</span></span>
<span class="rounded-full bg-orange-100/60 px-5 py-2 text-sm">
AI评分 <span class="text-md text-orange-600 font-bold">{{ team.Points }}</span>
</span>
<span class="rounded-full bg-green-100/60 px-5 py-2 text-sm">
评委评分 <span class="text-md text-green-600 font-bold">{{ team.ResultPotins }}</span>
</span>
</div>
<!-- Answer Image Area -->

View File

@ -154,9 +154,10 @@ const showCards = ref(false)
// 默认按最小屏幕2列计算如果超过2排则调整padding
const needCompactPadding = computed(() => {
// 假设最小屏幕每排2个卡片w-[44%]
const cardsPerRow = 2
const totalRows = Math.ceil(questions.value.length / cardsPerRow)
return totalRows > 2
// const cardsPerRow = 2
// const totalRows = Math.ceil(questions.value.length / cardsPerRow)
// return totalRows >= 2
return questions.value.length > 7 // 如果超过8张卡片就使用紧凑间距
})
// 3D 悬停效果逻辑

View File

@ -143,7 +143,7 @@ const titleBgTypeNum = computed(() => RoundType.value === 0 ? 3 : 4)
@next="handleCardClick(question as Api.Competition.CurrentQuestionResponse)" @back="handleBack"
>
<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 v-if="question" class="mb-10 max-w-85% text-center">
<div class="text-4xl text-gray-800 font-medium leading-relaxed tracking-wide">
{{ questionTitle || '' }}
</div>

View File

@ -284,12 +284,8 @@ const titleBgTypeNum = computed(() => RoundType.value === 0 ? 3 : 4) || 3
class="relative z-20 h-64 max-w-5xl w-full flex flex-col shrink-0 border-2 border-blue-300 rounded-xl bg-white/90 p-4 shadow-lg backdrop-blur"
>
<!-- 图表标题栏 -->
<div v-if="isStarted" class="mb-4 flex items-center justify-between px-2">
<div class="flex items-center gap-2">
<div class="h-3 w-3 rotate-45 bg-orange-400" />
<span class="text-lg text-blue-800 font-bold">答题进度</span>
<span class="text-md text-gray-400">仅为ai判定不代表最终结果</span>
</div>
<div v-if="isStarted" class="z-index-999 relative mb-4">
<img src="@/assets/imgs/user/rank-bg-kuang.png" alt="答题进度" class="z-index-999 absolute left--26px top--22px h-auto max-h-16 w-auto">
</div>
<!-- 柱状图ECharts -->

View File

@ -24,6 +24,35 @@ const dictationParts = computed(() => {
return []
})
// 动态计算第二个框的样式(根据文字长度)
const secondPartStyle = computed(() => {
if (dictationParts.value.length < 2)
return { fontSize: 'text-5xl', letterSpacing: 'tracking-widest' }
const secondPart = dictationParts.value[1]
const length = secondPart.length
// 根据文字长度返回不同的样式配置
if (length <= 16) {
return {
fontSize: 'text-5xl', // 51.2px - 保持大字体
letterSpacing: 'tracking-widest', // 0.4em - 正常字间距
}
}
else if (length <= 20) {
return {
fontSize: 'text-4xl', // 40px - 稍微缩小
letterSpacing: 'tracking-wide', // 0.2em - 缩小字间距
}
}
else {
return {
fontSize: 'text-3xl', // 33.6px - 更小一点
letterSpacing: 'tracking-normal', // 0.05em - 最小字间距
}
}
})
// 联想对对碰:上方词组
const associationWords = computed(() => {
const name = currentQuestionDetail.value?.Name || ''
@ -71,13 +100,18 @@ watch(
<!-- 模板A: 汉字听写1-提示 (拼音+提示) -->
<div v-if="template === QuestionCategoryEnum.ChineseCharacterDictation1" class="text-center">
<div class="mb-6 inline-block text-5xl font-bold leading-none">
<div class="flex items-center justify-center gap-4">
<div class="flex items-center justify-start gap-4">
<!-- 第一个框拼音 -->
<div class="short-content-bg pinyin-text text-6xl">
{{ dictationParts[0] }}
</div>
<!-- 第二个框意思提示 -->
<div
v-for="(part, index) in dictationParts" :key="`part-${index}`"
class="text-4xl"
:class="index === 0 ? 'short-content-bg' : 'content-with-bg'"
v-if="dictationParts.length > 1" class="content-with-bg pinyin-text !max-w-85%"
:class="[secondPartStyle.fontSize, secondPartStyle.letterSpacing]"
>
{{ part }}
{{ dictationParts[1] }}
</div>
</div>
</div>
@ -88,8 +122,11 @@ watch(
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]">
<div class="mb-2 text-center text-2xl">
{{ title }}
</div>
<div class="mb-1 text-gray-800 font-bold leading-relaxed">
<div class="rich-content content-with-bg pinyin-text ml-3 mt-4 flex text-7xl !max-w-100% !px-16 !py-8 !tracking-[0.08em]">
{{ content }}
</div>
</div>
@ -98,7 +135,7 @@ watch(
<!-- 模板C: 汉字加一加 (提示+部件) -->
<div v-else-if="template === QuestionCategoryEnum.CharacterRadicalAddition" class="text-center">
<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]">
<div class="rich-content ml-3 mt-4 flex gap-1 text-6xl tracking-[0.06em]">
请写出含有 <span class="text-color-red !text-6xl">{{ content }}</span>的汉字书写时间为60秒
</div>
</div>
@ -107,7 +144,7 @@ watch(
<!-- 模板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">
<div class="rich-content ml-3 mt-4 flex gap-1 text-5xl">
{{ content }}
</div>
</div>
@ -115,8 +152,8 @@ watch(
<!-- 模板E: 成语-文字要求 -->
<div v-else-if="template === QuestionCategoryEnum.IdiomWriting2" class="text-center">
<div class="mb-8 text-4xl text-gray-800 font-bold">
<NImage :src="ImageUrl" preview-disabled width="300" object-fit="contain" />
<div class="mb-2">
<img :src="ImageUrl" preview-disabled class="max-h-380px max-w-700px" object-fit="cover">
</div>
</div>
@ -173,7 +210,7 @@ watch(
v-else-if="template === QuestionCategoryEnum.CharacterWordFlowerDrinkingGame"
class="w-full flex flex-col items-center"
>
<div class="options-container text-5xl">
<div class="options-container text-6xl">
请回答带有{{ content }}的诗词
</div>
</div>
@ -408,7 +445,8 @@ watch(
:deep(p),
:deep(span),
:deep(div) {
font-size: 2rem; /* text-5xl */
font-size: 2.2rem;
/* text-5xl */
}
}
@ -416,16 +454,41 @@ watch(
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;
padding: 30px 50px;
/* 优化长文本显示 */
max-width: 80%;
/* 限制最大宽度 */
min-height: 120px;
/* 最小高度,与 short-content-bg 保持一致 */
word-break: keep-all;
/* 避免中文单词被截断 */
white-space: normal;
/* 允许换行 */
line-height: 1.5;
/* 行高,让换行更美观 */
display: inline-flex;
/* 使用 flex 布局 */
align-items: center;
/* 垂直居中 */
justify-content: center;
/* 水平居中 */
text-align: center;
/* 文字居中 */
}
.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;
padding: 20px 80px;
min-height: 120px;
/* 最小高度,与 content-with-bg 保持一致 */
display: inline-flex;
/* 使用 flex 布局 */
align-items: center;
/* 垂直居中 */
justify-content: center;
/* 水平居中 */
}
.association-word {
@ -453,4 +516,35 @@ watch(
font-weight: bold;
color: white;
}
/* 拼音字体样式 - 支持声调符号 */
.pinyin-text {
/* 字体回退顺序:
1. GB Pinyinok-B - 专业拼音字体
2. Microsoft YaHei - 微软雅黑Windows 系统自带,支持拼音)
3. PingFang SC - 苹方macOS 系统自带,支持拼音)
4. STHeiti - 华文黑体(支持拼音)
5. sans-serif - 系统默认无衬线字体
*/
font-family: 'GB Pinyinok-B', 'Microsoft YaHei', 'PingFang SC', 'STHeiti', 'SimHei', sans-serif;
font-weight: bold;
letter-spacing: 0.08em;
/* 确保拼音声调符号不被截断 */
line-height: 1.5;
}
/* 如果需要加载自定义拼音字体 */
@font-face {
font-family: 'GB Pinyinok-B';
/* 优先使用本地已安装的字体 */
src: local('GB Pinyinok-B'), local('GBPinyinok-B'), local('GB Pinyinok B');
/* 如果本地没有,则加载项目中的字体文件(需要先添加字体文件)*/
/* src: url('@/assets/fonts/GBPinyinok-B.woff2') format('woff2'),
url('@/assets/fonts/GBPinyinok-B.woff') format('woff'),
url('@/assets/fonts/GBPinyinok-B.ttf') format('truetype'); */
font-weight: normal;
font-style: normal;
font-display: swap;
/* 字体加载时先显示系统字体,加载完成后切换 */
}
</style>

View File

@ -49,7 +49,7 @@ function handleNext() {
<template>
<CompetitionLayout
:show-back="true" back-btn-text="返回" :show-next="true" :title="activityName" next-btn-text="下一步"
:show-back="true" :title-bg-type="2" back-btn-text="返回" :show-next="true" :title="activityName" next-btn-text="下一步"
@back="handleBack" @next="handleNext"
>
<div class="items-flex-start h-full w-full flex justify-center font-sans">

View File

@ -87,7 +87,10 @@ onMounted(() => {
</script>
<template>
<CompetitionLayout :show-back="true" back-btn-text="返回" :show-next="false" next-btn-text="下一步" :show-title="false" @next="handleNext" @back="handleBack">
<CompetitionLayout
:show-back="true" back-btn-text="返回" :show-next="false" next-btn-text="下一步" :show-title="false"
@next="handleNext" @back="handleBack"
>
<!-- Scroll Content -->
<div class="scroll-container">
<!-- 卷轴背景动画容器 -->
@ -113,7 +116,7 @@ onMounted(() => {
<div class="icon-wrapper">
<img src="@/assets/imgs/user/team-title-icon.webp" alt="team-icon" class="h-full w-full object-cover">
</div>
<div class="team-name">
<div class="team-name text-3xl">
{{ item.name }}
</div>
</div>
@ -181,6 +184,7 @@ onMounted(() => {
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-15px);
}
@ -189,7 +193,7 @@ onMounted(() => {
.title-section {
position: absolute;
top: 130px; // 使用固定像素值,确保在卷轴内部
left: 40px; // 距离左边缘的固定距离
left: 47px; // 距离左边缘的固定距离
z-index: 10;
.scroll-bg {
@ -207,8 +211,8 @@ onMounted(() => {
// 添加响应式适配
@media (max-width: 1920px) {
.title-section {
top: 70px;
left: 50px;
top: 140px;
left: 45px;
}
}
@ -316,14 +320,20 @@ onMounted(() => {
}
.team-name {
font-size: 1.5rem;
// min-width: 130px;
// width: 300px;
// height: 120px;
min-height: 60px;
min-width: 250px;
line-height: 60px;
color: #333;
font-weight: bold;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8);
background: url('@/assets/imgs/user/team-title-bg.svg') no-repeat center center;
background-size: contain;
background: url('@/assets/imgs/user/team-title-bg.png') no-repeat center center;
background-size: 100% 100%;
padding: 15px 30px;
border-radius: 10px;
text-align: center;
box-sizing: border-box;
}
}
</style>