feat: 新增测试页面并优化用户界面及API调用

- 新增测试页面 `/test`,用于展示题目答案的交互式网格和列表布局
- 为 CompetitionLayout 组件添加标题背景类型支持,优化视觉一致性
- 更新 `fetchUpdateCurrentQuestionUse` API 参数,简化调用逻辑
- 优化用户界面细节,包括图标替换、样式调整和布局改进
This commit is contained in:
2026-02-27 08:37:45 +08:00
parent f1ff053b68
commit e70409082a
16 changed files with 356 additions and 63 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.6 MiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 825 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 4.0 MiB

View File

@ -1,7 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import VScaleScreen from 'v-scale-screen' import VScaleScreen from 'v-scale-screen'
import { onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import _defaultBg from '@/assets/imgs/user/home-bg.svg' import _defaultBg from '@/assets/imgs/user/home-bg.svg'
import titleBg2 from '@/assets/imgs/user/title-bg2.svg'
import _defaultTitleBg from '@/assets/imgs/user/title-bg.svg'
import { useRouterPush } from '@/hooks/common/router' import { useRouterPush } from '@/hooks/common/router'
interface Props { interface Props {
@ -25,6 +27,8 @@ interface Props {
btnTheme?: 'primary' | 'light' btnTheme?: 'primary' | 'light'
/** 是否禁用下一步按钮 */ /** 是否禁用下一步按钮 */
nextDisabled?: boolean nextDisabled?: boolean
/** 标题区域背景图片URL不传则使用默认 title-bg.svg */
titleBgType?: number
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@ -38,6 +42,7 @@ const props = withDefaults(defineProps<Props>(), {
backBtnText: undefined, backBtnText: undefined,
btnTheme: 'primary', btnTheme: 'primary',
nextDisabled: false, nextDisabled: false,
titleBgType: 1,
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@ -45,8 +50,15 @@ const emit = defineEmits<{
(e: 'next'): void (e: 'next'): void
}>() }>()
const titleBgUrlMapping: Record<number, string> = {
1: _defaultTitleBg,
2: titleBg2,
}
const { routerPushByKey } = useRouterPush() const { routerPushByKey } = useRouterPush()
const titleBgUrl = computed(() => titleBgUrlMapping[props.titleBgType] || _defaultBg)
// const router = useRouter() // const router = useRouter()
const innerBgUrl = ref(_defaultBg) const innerBgUrl = ref(_defaultBg)
@ -108,7 +120,7 @@ onMounted(() => {
<section v-if="showTitle" class="title-section"> <section v-if="showTitle" class="title-section">
<Transition name="fade-slide-down" appear> <Transition name="fade-slide-down" appear>
<div v-if="showContent" class="title-section"> <div v-if="showContent" class="title-section">
<div class="scroll-bg"> <div class="scroll-bg" :style="{ backgroundImage: `url(${titleBgUrl})` }">
<h1 class="main-title"> <h1 class="main-title">
{{ title }} {{ title }}
</h1> </h1>
@ -123,16 +135,20 @@ onMounted(() => {
</main> </main>
<!-- Footer Action --> <!-- Footer Action -->
<footer class="page-footer w-full flex items-center justify-between px-20" :class="[actionPosition, btnTheme]"> <footer
<button class="action-btn back-btn" :class="{ 'text-btn': backBtnText }" :style="{ visibility: showBack ? 'visible' : 'hidden' }" @click="handleBack"> class="page-footer w-full flex items-center justify-between px-20"
:class="[actionPosition, btnTheme]"
>
<button
class="action-btn back-btn" :class="{ 'text-btn': backBtnText }"
:style="{ visibility: showBack ? 'visible' : 'hidden' }" @click="handleBack"
>
<span v-if="backBtnText" class="btn-text">{{ backBtnText }}</span> <span v-if="backBtnText" class="btn-text">{{ backBtnText }}</span>
<SvgIcon v-else icon="mdi:arrow-left" class="arrow-icon" /> <SvgIcon v-else icon="mdi:arrow-left" class="arrow-icon" />
</button> </button>
<button <button
v-if="showNext" v-if="showNext" class="action-btn next-btn text-btn" :class="{ 'is-disabled': nextDisabled }"
class="action-btn next-btn text-btn"
:class="{ 'is-disabled': nextDisabled }"
@click="handleNext" @click="handleNext"
> >
<span class="btn-text">{{ nextBtnText || '下一步' }}</span> <span class="btn-text">{{ nextBtnText || '下一步' }}</span>
@ -148,14 +164,14 @@ onMounted(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
.title-section { .title-section {
margin: 10px auto; margin: 10px auto;
width: 405px; width: 412px;
height: 150px; height: 165px;
.scroll-bg { .scroll-bg {
width: 100%; width: 100%;
height: 100%; height: 100%;
position: relative; position: relative;
background: url('@/assets/imgs/user/title-bg.svg') no-repeat center center; background: no-repeat center center;
background-size: 100% 100%; background-size: 100% 100%;
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -31,6 +31,7 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
results: () => import("@/views/results/index.vue"), results: () => import("@/views/results/index.vue"),
"template_template-detail": () => import("@/views/template/template-detail/index.vue"), "template_template-detail": () => import("@/views/template/template-detail/index.vue"),
"template_template-list": () => import("@/views/template/template-list/index.vue"), "template_template-list": () => import("@/views/template/template-list/index.vue"),
test: () => import("@/views/test/index.vue"),
user_analysis: () => import("@/views/user/analysis/index.vue"), user_analysis: () => import("@/views/user/analysis/index.vue"),
user_cover: () => import("@/views/user/cover/index.vue"), user_cover: () => import("@/views/user/cover/index.vue"),
user_draw: () => import("@/views/user/draw/index.vue"), user_draw: () => import("@/views/user/draw/index.vue"),

View File

@ -223,6 +223,15 @@ export const generatedRoutes: GeneratedRoute[] = [
} }
] ]
}, },
{
name: 'test',
path: '/test',
component: 'layout.base$view.test',
meta: {
title: 'test',
i18nKey: 'route.test'
}
},
{ {
name: 'user', name: 'user',
path: '/user', path: '/user',

View File

@ -182,6 +182,7 @@ const routeMap: RouteMap = {
"template": "/template", "template": "/template",
"template_template-detail": "/template/template-detail", "template_template-detail": "/template/template-detail",
"template_template-list": "/template/template-list", "template_template-list": "/template/template-list",
"test": "/test",
"user": "/user", "user": "/user",
"user_analysis": "/user/analysis", "user_analysis": "/user/analysis",
"user_cover": "/user/cover", "user_cover": "/user/cover",

View File

@ -1,10 +1,10 @@
import { request } from '../request' import { request } from '../request'
/** 评委消费题目,表示用了该题目 */ /** 评委消费题目,表示用了该题目 */
export function fetchUpdateCurrentQuestionUse(data: Api.Competition.GetCurrentQuestionParams) { export function fetchUpdateCurrentQuestionUse(TeamGroupID: string) {
return request<App.Service.Response>({ return request<App.Service.Response>({
url: `/Base/ActivityMain/UpdateCurrentQuestionUse?ActivityID=${data.ActivityID}&GroupID=${data.GroupID}&RoundType=${data.RoundType}&isUse=${data.isUse}`, url: `/Base/ActivityMain/UpdateCurrentQuestionUse?TeamGroupID=${TeamGroupID}`,
method: 'post', method: 'post',
data, data: { TeamGroupID },
}) })
} }

View File

@ -36,6 +36,7 @@ declare module "@elegant-router/types" {
"template": "/template"; "template": "/template";
"template_template-detail": "/template/template-detail"; "template_template-detail": "/template/template-detail";
"template_template-list": "/template/template-list"; "template_template-list": "/template/template-list";
"test": "/test";
"user": "/user"; "user": "/user";
"user_analysis": "/user/analysis"; "user_analysis": "/user/analysis";
"user_cover": "/user/cover"; "user_cover": "/user/cover";
@ -88,6 +89,7 @@ declare module "@elegant-router/types" {
| "rank" | "rank"
| "results" | "results"
| "template" | "template"
| "test"
| "user" | "user"
>; >;
@ -121,6 +123,7 @@ declare module "@elegant-router/types" {
| "results" | "results"
| "template_template-detail" | "template_template-detail"
| "template_template-list" | "template_template-list"
| "test"
| "user_analysis" | "user_analysis"
| "user_cover" | "user_cover"
| "user_draw" | "user_draw"

View File

@ -2,15 +2,20 @@
import { useIntervalFn } from '@vueuse/core' import { useIntervalFn } from '@vueuse/core'
import { NButton } from 'naive-ui' import { NButton } from 'naive-ui'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { onUnmounted, ref } from 'vue' import { computed, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchUpdateCurrentQuestionUse } from '@/service/api/result' import { fetchUpdateCurrentQuestionUse } from '@/service/api/result'
import { useActivityInfoStore } from '@/store/modules/activityinfo' import { useActivityInfoStore } from '@/store/modules/activityinfo'
import GroupTabs, { type GroupItem } from './modules/GroupTabs.vue' import GroupTabs, { type GroupItem } from './modules/GroupTabs.vue'
import TeamResultCard, { type AICharacter, type TeamResult } from './modules/TeamResultCard.vue' import TeamResultCard, { type AICharacter, type TeamResult } from './modules/TeamResultCard.vue'
const activityInfoStore = useActivityInfoStore() const activityInfoStore = useActivityInfoStore()
// eslint-disable-next-line unused-imports/no-unused-vars
const { activityId, groupId } = storeToRefs(activityInfoStore) const { activityId, groupId } = storeToRefs(activityInfoStore)
const route = useRoute()
const TeamGroupID = computed(() => route.query?.TeamGroupID as string)
// --- 模拟数据和状态 --- // --- 模拟数据和状态 ---
const groups = ref<GroupItem[]>(Array.from({ length: 8 }).map((_, i) => ({ const groups = ref<GroupItem[]>(Array.from({ length: 8 }).map((_, i) => ({
@ -173,19 +178,15 @@ function handleNext() {
} }
function handlePublish() { function handlePublish() {
fetchGetQuestionDetailData() if (TeamGroupID.value) {
window.$message?.success('结果已发布') fetchGetQuestionDetailData()
window.$message?.success('结果已发布')
}
} }
async function fetchGetQuestionDetailData() { async function fetchGetQuestionDetailData() {
const params = {
ActivityID: Number(activityId.value),
GroupID: Number(groupId.value),
RoundType: 0 as Api.Competition.CompetitionRoundType,
}
try { try {
const { data: outlineData, error } = await fetchUpdateCurrentQuestionUse({ ...params, isUse: true }) const { data: outlineData, error } = await fetchUpdateCurrentQuestionUse(TeamGroupID.value)
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(outlineData, 'outlineData') console.log(outlineData, 'outlineData')
if (error) { if (error) {

View File

@ -131,7 +131,7 @@ async function handleSave() {
const params: Api.Template.AddTemplateParams = { const params: Api.Template.AddTemplateParams = {
id, id,
pageNo: '1', pageNo: '1761.172.8.66',
name: templateInfo.value.name, name: templateInfo.value.name,
backGroundUrl: templateInfo.value.backGroundUrl || '', backGroundUrl: templateInfo.value.backGroundUrl || '',
width: Math.round(templateInfo.value.width), width: Math.round(templateInfo.value.width),

View File

@ -0,0 +1,229 @@
<script lang="ts" setup>
import { Icon } from '@iconify/vue'
import { NCard, NImage, NSpace } from 'naive-ui'
import { ref, watch } from 'vue'
interface AnswerItem {
id: number
label?: string
imageUrl: string // 在真实场景中是学生答题截图的URL
status: 'correct' | 'wrong' // 初始状态由大模型判定
type: 'grid' | 'line' // 'grid'对应方格题(如汉字),'line'对应横线题(如词语)
}
interface QuestionGroup {
id: number
title: string
items: AnswerItem[]
layout: 'grid' | 'list' // 布局方式
}
interface Round {
id: number
title: string
subTitle?: string
groups: QuestionGroup[]
}
// 模拟数据
const rounds = ref<Round[]>([
{
id: 1,
title: '第一轮',
subTitle: '汉字大比拼',
groups: [
{
id: 101,
title: '汉字听写 1',
layout: 'grid',
items: [
{ id: 1, label: '1', imageUrl: 'https://placehold.co/100x100?text=汉', status: 'correct', type: 'grid' },
{ id: 2, label: '2', imageUrl: 'https://placehold.co/100x100?text=字', status: 'correct', type: 'grid' },
{ id: 3, label: '3', imageUrl: 'https://placehold.co/100x100?text=听', status: 'correct', type: 'grid' },
{ id: 4, label: '4', imageUrl: 'https://placehold.co/100x100?text=写', status: 'correct', type: 'grid' },
{ id: 5, label: '5', imageUrl: 'https://placehold.co/100x100?text=一', status: 'correct', type: 'grid' },
],
},
{
id: 102,
title: '汉字听写 2',
layout: 'grid',
items: [
{ id: 6, label: '1', imageUrl: 'https://placehold.co/100x100?text=错', status: 'wrong', type: 'grid' },
{ id: 7, label: '2', imageUrl: 'https://placehold.co/100x100?text=误', status: 'wrong', type: 'grid' },
{ id: 8, label: '3', imageUrl: 'https://placehold.co/100x100?text=示', status: 'wrong', type: 'grid' },
{ id: 9, label: '4', imageUrl: 'https://placehold.co/100x100?text=例', status: 'wrong', type: 'grid' },
{ id: 10, label: '5', imageUrl: 'https://placehold.co/100x100?text=!', status: 'wrong', type: 'grid' },
],
},
{
id: 103,
title: '汉字加一加',
layout: 'grid',
items: Array.from({ length: 10 }).map((_, i) => ({
id: 20 + i,
imageUrl: `https://placehold.co/100x100?text=${i + 1}`,
status: i === 4 ? 'wrong' : 'correct', // 第5个模拟错误
type: 'grid',
})),
},
],
},
{
id: 2,
title: '第一轮',
subTitle: '词语大比拼',
groups: [
{
id: 201,
title: '词语听写',
layout: 'list',
items: [
{ id: 301, label: '1.', imageUrl: 'https://placehold.co/400x60?text=词语答案一', status: 'correct', type: 'line' },
{ id: 302, label: '2.', imageUrl: 'https://placehold.co/400x60?text=词语答案二', status: 'correct', type: 'line' },
],
},
{
id: 202,
title: '成语写一写 1',
layout: 'list',
items: [
{ id: 303, label: '1.', imageUrl: 'https://placehold.co/200x60?text=成语一', status: 'correct', type: 'line' },
{ id: 304, label: '2.', imageUrl: 'https://placehold.co/200x60?text=成语二', status: 'wrong', type: 'line' },
{ id: 305, label: '3.', imageUrl: 'https://placehold.co/200x60?text=成语三', status: 'wrong', type: 'line' },
],
},
{
id: 203,
title: '成语写一写 2',
layout: 'list',
items: [
{ id: 306, label: '1.', imageUrl: 'https://placehold.co/600x60?text=长成语答案测试', status: 'correct', type: 'line' },
{ id: 307, label: '2.', imageUrl: 'https://placehold.co/600x60?text=另一个答案', status: 'correct', type: 'line' },
],
},
],
},
])
// 切换状态逻辑:点击后 Correct <-> Wrong
function toggleStatus(item: AnswerItem) {
item.status = item.status === 'correct' ? 'wrong' : 'correct'
}
watch(() => rounds.value, (val) => {
// eslint-disable-next-line no-console
console.log(val, 'v')
}, {
deep: true,
})
</script>
<template>
<div class="min-h-screen bg-gray-50 p-4">
<NSpace vertical size="large">
<NCard v-for="round in rounds" :key="round.id" :title="`${round.title} · ${round.subTitle}`" size="huge">
<div class="flex flex-col gap-8">
<div v-for="group in round.groups" :key="group.id">
<h3 class="mb-4 text-lg text-gray-700 font-medium">
{{ group.title }}
</h3>
<!-- Grid Layout (Grid Items) -->
<div v-if="group.layout === 'grid'" class="flex flex-wrap gap-4">
<div
v-for="item in group.items"
:key="item.id"
class="group relative cursor-pointer transition-all duration-200"
@click="toggleStatus(item)"
>
<!-- Image Container with Status Border -->
<div
class="relative overflow-hidden border-2 rounded-md border-dashed bg-white p-1"
:class="[
item.status === 'correct' ? 'border-green-500' : 'border-red-500',
]"
>
<NImage
:src="item.imageUrl"
preview-disabled
object-fit="contain"
:class="group.layout === 'grid' ? 'w-24 h-24' : ''"
/>
<!-- Status Icon Overlay -->
<div class="absolute z-10 rounded-full bg-white -right-3 -top-3">
<Icon
v-if="item.status === 'correct'"
icon="mdi:check-circle"
class="text-2xl text-green-500"
/>
<Icon
v-else
icon="mdi:close-circle"
class="text-2xl text-red-500"
/>
</div>
</div>
<!-- Label if exists -->
<div v-if="item.label" class="mt-1 text-center text-sm text-gray-500">
{{ item.label }}
</div>
</div>
</div>
<!-- List Layout (Line Items) -->
<div v-else class="flex flex-col gap-4">
<!-- Using a grid for lines to handle multiple per row if needed, or just flex-wrap -->
<div class="flex flex-wrap gap-x-8 gap-y-4">
<div
v-for="item in group.items"
:key="item.id"
class="relative cursor-pointer transition-all duration-200"
@click="toggleStatus(item)"
>
<div class="flex items-center gap-2">
<span v-if="item.label" class="w-6 text-gray-600 font-medium">{{ item.label }}</span>
<div
class="relative overflow-hidden border-2 rounded-md border-dashed bg-white px-2 py-1"
:class="[
item.status === 'correct' ? 'border-green-500' : 'border-red-500',
]"
>
<NImage
:src="item.imageUrl"
preview-disabled
object-fit="contain"
height="50"
/>
<!-- Status Icon Overlay -->
<div class="absolute z-10 rounded-full bg-white shadow-sm -right-3 -top-3">
<Icon
v-if="item.status === 'correct'"
icon="mdi:check-circle"
class="text-2xl text-green-500"
/>
<Icon
v-else
icon="mdi:close-circle"
class="text-2xl text-red-500"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</NCard>
</NSpace>
</div>
</template>
<style scoped>
/* Ensure icons are visible and positioned correctly */
</style>

View File

@ -134,7 +134,8 @@ onMounted(async () => {
<template> <template>
<CompetitionLayout <CompetitionLayout
:show-back="true" :show-next="showNextButton" :title="activityName" @back="handleBack" :show-back="true" :show-next="showNextButton" :title="activityName" :title-bg-type="2"
@back="handleBack"
@next="handleNext" @next="handleNext"
> >
<div class="h-screen w-full overflow-y-auto font-sans"> <div class="h-screen w-full overflow-y-auto font-sans">

View File

@ -70,24 +70,15 @@ onMounted(() => {
<template> <template>
<CompetitionLayout <CompetitionLayout
:show-back="true" :show-back="true" :show-next="isAllEnd" next-btn-text="查看队伍结果" :title-bg-type="2"
:show-next="isAllEnd" title="展示组别" @back="handleBack" @next="handleNext"
next-btn-text="查看队伍结果"
title="展示组别"
@back="handleBack"
@next="handleNext"
> >
<!-- Groups Grid --> <!-- Groups Grid -->
<div class="groups-container"> <div class="groups-container">
<TransitionGroup name="list-anim" tag="div" class="groups-grid"> <TransitionGroup name="list-anim" tag="div" class="groups-grid">
<div <div
v-for="(item, index) in groups" v-for="(item, index) in groups" v-show="showContent" :key="item.Id" class="group-item"
v-show="showContent" :class="{ 'is-disabled': item.IsEnd }" :style="{ '--delay': `${index * 0.05}s` }" @click="selectGroup(item)"
:key="item.Id"
class="group-item"
:class="{ 'is-disabled': item.IsEnd }"
:style="{ '--delay': `${index * 0.05}s` }"
@click="selectGroup(item)"
> >
<div class="icon-wrapper"> <div class="icon-wrapper">
<div class="flower-bg" /> <div class="flower-bg" />

View File

@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { NCarousel } from 'naive-ui' import { NCarousel, NImage } from 'naive-ui'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import beacon from '@/assets/imgs/user/event-card-bg-icon.svg'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue' import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { useRouterPush } from '@/hooks/common/router' import { useRouterPush } from '@/hooks/common/router'
import { fetchGetActivityList } from '@/service/api/competition' import { fetchGetActivityList } from '@/service/api/competition'
@ -81,7 +82,8 @@ onMounted(() => {
<template> <template>
<CompetitionLayout <CompetitionLayout
:show-back="false" :show-next="false" title="赛事总览" action-position="top" back-btn-text="返回" :show-back="false" :show-next="false" title="赛事总览" action-position="top" back-btn-text="返回"
btn-theme="light" @back="handleBack" @next="handleNext" btn-theme="light" :title-bg-type="2" @back="handleBack"
@next="handleNext"
> >
<!-- Cards Section --> <!-- Cards Section -->
<div class="cards-container"> <div class="cards-container">
@ -106,7 +108,11 @@ onMounted(() => {
<div class="card-top-bar" /> <div class="card-top-bar" />
<div class="card-body"> <div class="card-body">
<div class="icon-wrapper"> <div class="icon-wrapper">
<SvgIcon :local-icon="item.icon" class="event-icon" /> <NImage
:src="beacon"
preview-disabled
object-fit="contain"
/>
</div> </div>
<div class="text-content"> <div class="text-content">
<h3 class="event-title"> <h3 class="event-title">
@ -168,20 +174,12 @@ onMounted(() => {
} }
.icon-wrapper { .icon-wrapper {
width: 100px; width: 170px;
height: 100px; height: 161px;
background: rgba(255, 255, 255, 0.6);
border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 30px; margin: 30px 0;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05);
.event-icon {
font-size: 48px;
color: $secondary-color;
}
} }
.text-content { .text-content {
@ -203,8 +201,8 @@ onMounted(() => {
transform: translateY(-10px); transform: translateY(-10px);
.icon-wrapper { .icon-wrapper {
transform: scale(1.1) rotate(5deg); transform: scale(1.1) rotate(2deg);
transition: transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); transition: transform 0.8s cubic-bezier(0.175, 0.885, 0.32, 1.275);
} }
} }
} }

View File

@ -62,8 +62,6 @@ function handleNext() {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@import '@/styles/scss/variables.scss';
.rules-content-wrapper { .rules-content-wrapper {
width: 100%; width: 100%;
max-height: 80%; max-height: 80%;
@ -76,7 +74,6 @@ function handleNext() {
margin-top: 10px; margin-top: 10px;
font-size: 28px; font-size: 28px;
line-height: 2; line-height: 2;
// color: #5d4037; // Dark brown/grey text
.intro-text { .intro-text {
margin-bottom: 30px; margin-bottom: 30px;