feat(admin): 新增用户端答题流程页面与相关功能模块

- 新增用户端答题流程主页面,包含封面、介绍、答题、答题图解四个状态
- 新增用户端组件:封面、介绍页、答题页、答题图解页、题目渲染器及通用布局组件
- 新增用户端类型定义、模拟数据及业务常量
- 新增用户路由及类型声明,包含组别和队伍选择页面
- 新增管理端首页模块:数据卡片、头部横幅、折线图、饼图、创意横幅和项目新闻
- 新增富文本编辑器组件,支持图片和视频上传至OSS
- 优化题库分类树,改为扁平化列表结构
- 完善比赛创建功能,增加轮次类型、题目分数类型及关联AP字段
- 修复模板删除函数调用参数错误
- 添加 pinyin-pro 依赖以支持拼音处理功能
This commit is contained in:
2026-02-05 18:00:11 +08:00
parent ce5ee2a289
commit ca42c6a876
49 changed files with 3569 additions and 179 deletions

View File

@ -49,6 +49,7 @@
"path-browserify": "^1.0.1",
"pdfjs-dist": "^5.4.530",
"pinia": "3.0.4",
"pinyin-pro": "^3.28.0",
"quill-toolbar-tip": "^0.1.0",
"tailwind-merge": "3.4.0",
"vue": "3.5.26",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 825 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.2 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

View File

@ -0,0 +1,116 @@
<script setup lang="ts">
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { onBeforeUnmount, shallowRef } from 'vue'
import { getAliOssTokenAxios } from '@/service/api/upload'
import { browserPathJoin } from '@/utils/date'
import { initOSSClient, uploadFileToOSS } from '@/utils/oss'
import '@wangeditor/editor/dist/css/style.css'
interface Props {
modelValue: string
placeholder?: string
mode?: 'default' | 'simple'
height?: string
path?: string
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
placeholder: '请输入内容...',
mode: 'default',
height: '300px',
path: 'temp',
})
const emit = defineEmits(['update:modelValue'])
// Editor Logic
const editorRef = shallowRef()
const toolbarConfig = {
excludeKeys: [],
}
type InsertFnType = (url: string, alt: string, href: string) => void
async function customUpload(file: File, insertFn: InsertFnType) {
try {
const { data: tokenData, error: tokenError } = await getAliOssTokenAxios()
if (tokenError || !tokenData) {
window.$message?.error('获取上传凭证失败')
return
}
const client = initOSSClient(tokenData?.data || {})
const path = `${props.path}/${Date.now()}/${file.name}`
await uploadFileToOSS(client, file, path)
const url = browserPathJoin(import.meta.env.VITE_BASE_OSS_URL, path)
insertFn(url, file.name, url)
}
catch (error) {
console.error('上传失败', error)
window.$message?.error('上传失败')
}
}
const editorConfig = {
placeholder: props.placeholder,
MENU_CONF: {
uploadImage: {
customUpload,
},
uploadVideo: {
customUpload,
},
},
}
// 监听 placeholder 变化
// watch(() => props.placeholder, (_newVal) => {
// if (editorRef.value) {
// // 似乎 wangeditor 不支持动态修改 placeholder这里作为占位
// }
// })
function handleCreated(editor: any) {
editorRef.value = editor
}
function handleChange(editor: any) {
emit('update:modelValue', editor.getHtml())
}
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null)
return
editor.destroy()
})
</script>
<template>
<div class="overflow-hidden border border-gray-200 rounded-lg bg-white">
<Toolbar
style="border-bottom: 1px solid #eee"
:editor="editorRef"
:default-config="toolbarConfig"
:mode="mode"
/>
<div :style="{ height: props.height }">
<Editor
:model-value="modelValue"
:style="{ height: '100%', overflowY: 'hidden' }"
:default-config="editorConfig"
:mode="mode"
@on-created="handleCreated"
@on-change="handleChange"
/>
</div>
</div>
</template>
<style scoped>
:deep(.w-e-text-container) {
background-color: transparent;
}
</style>

View File

@ -0,0 +1,222 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import _defaultBg from '@/assets/imgs/home-bg.svg'
interface Props {
/** 是否显示返回按钮 */
showBack?: boolean
/** 是否显示下一步按钮 */
showNext?: boolean
/** 背景图片URL如果不传则尝试内部获取或使用默认 */
bgUrl?: string
}
const props = withDefaults(defineProps<Props>(), {
showBack: true,
showNext: true,
bgUrl: undefined,
})
const emit = defineEmits<{
(e: 'back'): void
(e: 'next'): void
}>()
// const router = useRouter()
const innerBgUrl = ref(_defaultBg)
async function fetchSystemConfig() {
// 如果外部传入了 bgUrl则优先使用外部的
if (props.bgUrl) {
innerBgUrl.value = props.bgUrl
}
// 模拟接口请求系统配置
// const res = await fetchConfig()
// if (res.bgUrl) innerBgUrl.value = res.bgUrl
// 这里的逻辑保持与原 Home 页面一致:如果没有配置,则使用默认
if (!innerBgUrl.value)
innerBgUrl.value = _defaultBg
}
function handleBack() {
emit('back')
// 如果没有监听 back 事件,默认行为可以是路由返回
// if (!emit('back')) router.back() // 需要判断是否有监听器比较麻烦,这里建议由父组件控制或提供默认行为
}
function handleNext() {
emit('next')
}
onMounted(() => {
fetchSystemConfig()
})
</script>
<template>
<div class="competition-layout" :style="innerBgUrl ? { backgroundImage: `url(${innerBgUrl})` } : {}">
<!-- Header -->
<header class="page-header text-[30px] text-[#333333] font-bold">
<span class="org-name">教育学会</span>
<span class="org-name">树人研究院</span>
</header>
<div class="glass-overlay">
<!-- Main Content Slot -->
<main class="main-content">
<slot />
</main>
<!-- Footer Action -->
<div class="page-footer w-full flex items-center justify-between px-20">
<button v-if="showBack" class="action-btn back-btn" @click="handleBack">
<SvgIcon icon="mdi:arrow-left" class="arrow-icon" />
</button>
<button v-if="showNext" class="action-btn next-btn" @click="handleNext">
<SvgIcon icon="mdi:arrow-right" class="arrow-icon" />
</button>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
// Variables - 保持一致
$primary-color: #4db6ac;
$secondary-color: #333333;
$accent-color: #d4af37;
$text-color: #004d40;
.competition-layout {
width: 100%;
height: 100%;
min-height: 100vh;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
display: flex;
align-items: center;
justify-content: center;
padding: 70px 65px;
overflow: hidden;
position: relative;
font-family: 'Noto Serif SC', serif;
// Default background decoration
&::before {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 40%;
background-repeat: no-repeat;
background-position: bottom;
background-size: cover;
z-index: 0;
pointer-events: none;
}
}
.glass-overlay {
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(8px);
border-radius: 30px;
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.1);
border: 1px solid rgba(255, 255, 255, 0.6);
display: flex;
flex-direction: column;
position: relative;
z-index: 1;
overflow: hidden;
}
.page-header {
position: absolute;
top: 0;
left: 0;
width: 100%;
display: flex;
justify-content: space-between;
padding: 20px 40px;
font-weight: 700;
color: $secondary-color;
z-index: 10;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
// justify-content: center; // 让内容决定位置,通常是 margin-top
margin: 40px auto;
z-index: 10;
padding-bottom: 60px;
width: 100%; // 确保内容宽度
}
.page-footer {
position: absolute;
bottom: 40px;
right: 0; // 默认在右下角
z-index: 20;
.action-btn {
width: 60px;
height: 60px;
border-radius: 50%;
border: 2px solid #fff;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
.arrow-icon {
font-size: 32px;
}
&:hover {
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
&.next-btn {
background: #ef5350; // 红色
box-shadow: 0 4px 10px rgba(239, 83, 80, 0.4);
&:hover {
background: #e53935;
}
}
&.back-btn {
background: #ffa726; // 橙色/黄色,区分于下一步
box-shadow: 0 4px 10px rgba(255, 167, 38, 0.4);
&:hover {
background: #fb8c00;
}
}
}
}
// Responsive
@media (max-width: 768px) {
.page-header {
padding: 15px 20px;
font-size: 1rem;
}
}
</style>

View File

@ -4,3 +4,13 @@ export enum PublishStatus {
Processing = 2,
Finished = 3,
}
export enum QuestionType {
SingleChoice = 'SingleChoice', // 单选题
MultipleChoice = 'MultipleChoice', // 多选题
TrueFalse = 'TrueFalse', // 判断题
Text = 'Text', // 文本类型
FillBlank = 'FillBlank', // 填空题
Image = 'image', // 图片类型
// 其他类型...
}

View File

@ -20,15 +20,18 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
500: () => import("@/views/_builtin/500/index.vue"),
"iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"),
login: () => import("@/views/_builtin/login/index.vue"),
"admin-home": () => import("@/views/admin-home/index.vue"),
"competition_competition-add": () => import("@/views/competition/competition-add/index.vue"),
"competition_competition-detail": () => import("@/views/competition/competition-detail/index.vue"),
"competition_competition-list": () => import("@/views/competition/competition-list/index.vue"),
dictionary: () => import("@/views/dictionary/index.vue"),
groups: () => import("@/views/groups/index.vue"),
home: () => import("@/views/home/index.vue"),
"question-store": () => import("@/views/question-store/index.vue"),
"rank_rank-detail": () => import("@/views/rank/rank-detail/index.vue"),
"rank_rank-list": () => import("@/views/rank/rank-list/index.vue"),
results: () => import("@/views/results/index.vue"),
teams: () => import("@/views/teams/index.vue"),
"template_template-detail": () => import("@/views/template/template-detail/index.vue"),
"template_template-list": () => import("@/views/template/template-list/index.vue"),
user: () => import("@/views/user/index.vue"),

View File

@ -39,6 +39,16 @@ export const generatedRoutes: GeneratedRoute[] = [
hideInMenu: true
}
},
{
name: 'admin-home',
path: '/admin-home',
component: 'layout.base$view.admin-home',
meta: {
title: 'admin-home',
i18nKey: 'route.admin-home',
hideInMenu: true
}
},
{
name: 'competition',
path: '/competition',
@ -94,15 +104,29 @@ export const generatedRoutes: GeneratedRoute[] = [
order: 6
}
},
{
name: 'groups',
path: '/groups',
component: 'layout.blank$view.groups',
meta: {
title: 'groups',
i18nKey: 'route.groups',
icon: 'material-symbols:group',
order: 2,
constant: true,
hideInMenu: true
}
},
{
name: 'home',
path: '/home',
component: 'layout.base$view.home',
component: 'layout.blank$view.home',
meta: {
title: 'home',
i18nKey: 'route.home',
icon: 'mdi:monitor-dashboard',
order: 99,
icon: 'material-symbols:home',
order: 0,
constant: true,
hideInMenu: true
}
},
@ -189,6 +213,17 @@ export const generatedRoutes: GeneratedRoute[] = [
order: 2
}
},
{
name: 'teams',
path: '/teams',
component: 'layout.blank$view.teams',
meta: {
title: 'teams',
i18nKey: 'route.teams',
constant: true,
hideInMenu: true
}
},
{
name: 'template',
path: '/template',

View File

@ -166,11 +166,13 @@ const routeMap: RouteMap = {
"403": "/403",
"404": "/404",
"500": "/500",
"admin-home": "/admin-home",
"competition": "/competition",
"competition_competition-add": "/competition/competition-add",
"competition_competition-detail": "/competition/competition-detail",
"competition_competition-list": "/competition/competition-list",
"dictionary": "/dictionary",
"groups": "/groups",
"home": "/home",
"iframe-page": "/iframe-page/:url",
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?",
@ -179,6 +181,7 @@ const routeMap: RouteMap = {
"rank_rank-detail": "/rank/rank-detail",
"rank_rank-list": "/rank/rank-list",
"results": "/results",
"teams": "/teams",
"template": "/template",
"template_template-detail": "/template/template-detail",
"template_template-list": "/template/template-list",

View File

@ -8,7 +8,18 @@ import { transformElegantRoutesToVueRoutes } from '../elegant/transform'
*
* @link https://github.com/reader-starjs/elegant-router?tab=readme-ov-file#custom-route
*/
const customRoutes: CustomRoute[] = []
const customRoutes: CustomRoute[] = [
{
name: 'admin',
path: '/admin',
redirect: '/competition/competition-list',
meta: {
title: 'competition/competition-list',
constant: true,
hideInMenu: true,
},
} as unknown as CustomRoute,
]
/** create routes when the auth route mode is static */
export function createStaticRoutes() {

View File

@ -76,7 +76,7 @@ abbr:where([title]) {
Remove the default font size and weight for headings.
*/
h1,
/* h1,
h2,
h3,
h4,
@ -84,7 +84,7 @@ h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
} */
/*
Reset links to optimize for opt-in styling instead of opt-out.

View File

@ -40,6 +40,8 @@ declare namespace Api {
name: string
/** question content */
questionContent: string
/** question type */
questionType: QuestionType
}
/** add question library params */

View File

@ -15,6 +15,7 @@ declare module 'vue' {
AppProvider: typeof import('./../components/common/app-provider.vue')['default']
BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default']
ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
CompetitionLayout: typeof import('./../components/custom/user/CompetitionLayout.vue')['default']
CountTo: typeof import('./../components/custom/count-to.vue')['default']
DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default']
ExceptionBase: typeof import('./../components/common/exception-base.vue')['default']
@ -153,7 +154,7 @@ declare module 'vue' {
PageHeader: typeof import('./../components/common/page-header.vue')['default']
PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
RestBasicEditor: typeof import('./../components/common/rest-basic-editor/rest-basic-editor.vue')['default']
RestBasicEditor: typeof import('./../components/common/rest-basic-editor/index.vue')['default']
RestDraggableResizable: typeof import('./../components/common/rest-draggable-resizable/rest-draggable-resizable.vue')['default']
RestHoverAciton: typeof import('./../components/common/rest-hover-aciton/rest-hover-aciton.vue')['default']
RestImgViewer: typeof import('./../components/common/rest-img-viewer/rest-img-viewer.vue')['default']
@ -169,6 +170,7 @@ declare module 'vue' {
TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default']
ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
UpFileDialog: typeof import('./../components/common/rest-basic-editor/components/up-file-dialog.vue')['default']
WangEditor: typeof import('./../components/common/wang-editor.vue')['default']
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
}
}
@ -178,6 +180,7 @@ declare global {
const AppProvider: typeof import('./../components/common/app-provider.vue')['default']
const BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default']
const ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
const CompetitionLayout: typeof import('./../components/custom/user/CompetitionLayout.vue')['default']
const CountTo: typeof import('./../components/custom/count-to.vue')['default']
const DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default']
const ExceptionBase: typeof import('./../components/common/exception-base.vue')['default']
@ -316,7 +319,7 @@ declare global {
const PageHeader: typeof import('./../components/common/page-header.vue')['default']
const PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
const ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
const RestBasicEditor: typeof import('./../components/common/rest-basic-editor/rest-basic-editor.vue')['default']
const RestBasicEditor: typeof import('./../components/common/rest-basic-editor/index.vue')['default']
const RestDraggableResizable: typeof import('./../components/common/rest-draggable-resizable/rest-draggable-resizable.vue')['default']
const RestHoverAciton: typeof import('./../components/common/rest-hover-aciton/rest-hover-aciton.vue')['default']
const RestImgViewer: typeof import('./../components/common/rest-img-viewer/rest-img-viewer.vue')['default']
@ -332,5 +335,6 @@ declare global {
const TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default']
const ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
const UpFileDialog: typeof import('./../components/common/rest-basic-editor/components/up-file-dialog.vue')['default']
const WangEditor: typeof import('./../components/common/wang-editor.vue')['default']
const WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
}

View File

@ -20,11 +20,13 @@ declare module "@elegant-router/types" {
"403": "/403";
"404": "/404";
"500": "/500";
"admin-home": "/admin-home";
"competition": "/competition";
"competition_competition-add": "/competition/competition-add";
"competition_competition-detail": "/competition/competition-detail";
"competition_competition-list": "/competition/competition-list";
"dictionary": "/dictionary";
"groups": "/groups";
"home": "/home";
"iframe-page": "/iframe-page/:url";
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?";
@ -33,6 +35,7 @@ declare module "@elegant-router/types" {
"rank_rank-detail": "/rank/rank-detail";
"rank_rank-list": "/rank/rank-list";
"results": "/results";
"teams": "/teams";
"template": "/template";
"template_template-detail": "/template/template-detail";
"template_template-list": "/template/template-list";
@ -71,14 +74,17 @@ declare module "@elegant-router/types" {
| "403"
| "404"
| "500"
| "admin-home"
| "competition"
| "dictionary"
| "groups"
| "home"
| "iframe-page"
| "login"
| "question-store"
| "rank"
| "results"
| "teams"
| "template"
| "user"
>;
@ -102,15 +108,18 @@ declare module "@elegant-router/types" {
| "500"
| "iframe-page"
| "login"
| "admin-home"
| "competition_competition-add"
| "competition_competition-detail"
| "competition_competition-list"
| "dictionary"
| "groups"
| "home"
| "question-store"
| "rank_rank-detail"
| "rank_rank-list"
| "results"
| "teams"
| "template_template-detail"
| "template_template-list"
| "user"

View File

@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useAppStore } from '@/store/modules/app'
import CardData from './modules/card-data.vue'
import CreativityBanner from './modules/creativity-banner.vue'
import HeaderBanner from './modules/header-banner.vue'
import LineChart from './modules/line-chart.vue'
import PieChart from './modules/pie-chart.vue'
import ProjectNews from './modules/project-news.vue'
const appStore = useAppStore()
const gap = computed(() => (appStore.isMobile ? 0 : 16))
</script>
<template>
<NSpace vertical :size="16">
<NAlert :title="$t('common.tip')" type="warning">
{{ $t('page.home.branchDesc') }}
</NAlert>
<HeaderBanner />
<CardData />
<NGrid :x-gap="gap" :y-gap="16" responsive="screen" item-responsive>
<NGi span="24 s:24 m:14">
<NCard :bordered="false" class="card-wrapper">
<LineChart />
</NCard>
</NGi>
<NGi span="24 s:24 m:10">
<NCard :bordered="false" class="card-wrapper">
<PieChart />
</NCard>
</NGi>
</NGrid>
<NGrid :x-gap="gap" :y-gap="16" responsive="screen" item-responsive>
<NGi span="24 s:24 m:14">
<ProjectNews />
</NGi>
<NGi span="24 s:24 m:10">
<CreativityBanner />
</NGi>
</NGrid>
</NSpace>
</template>
<style scoped></style>

View File

@ -0,0 +1,219 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
const router = useRouter()
interface GroupItem {
id: number
name: string
icon: string
}
const groups = ref<GroupItem[]>([
{ id: 1, name: '初赛一组', icon: 'activity' },
{ id: 2, name: '初赛七组', icon: 'cast' },
{ id: 3, name: '初赛八组', icon: 'chrome' },
{ id: 4, name: '初赛九组', icon: 'heart' },
{ id: 5, name: '初赛十组', icon: 'activity' },
{ id: 6, name: '初赛六组', icon: 'cast' },
{ id: 7, name: '初赛七组', icon: 'chrome' },
{ id: 8, name: '初赛八组', icon: 'heart' },
{ id: 9, name: '初赛九组', icon: 'activity' },
{ id: 10, name: '初赛十组', icon: 'cast' },
])
const showContent = ref(false)
function handleBack() {
router.push('/home')
}
function handleNext() {
router.push('/teams')
}
function selectGroup(_id: number) {
// 选择组别逻辑
router.push('/teams')
}
onMounted(() => {
setTimeout(() => {
showContent.value = true
}, 100)
})
</script>
<template>
<CompetitionLayout
:show-back="true"
:show-next="false"
@back="handleBack"
@next="handleNext"
>
<!-- Title Section -->
<Transition name="fade-slide-down" appear>
<div v-if="showContent" class="title-section">
<div class="scroll-bg">
<h1 class="main-title">
展示组别
</h1>
</div>
</div>
</Transition>
<!-- Groups Grid -->
<div class="groups-container">
<TransitionGroup name="list-anim" tag="div" class="groups-grid">
<div
v-for="(item, index) in groups"
v-show="showContent"
:key="item.id"
class="group-item"
:style="{ '--delay': `${index * 0.05}s` }"
@click="selectGroup(item.id)"
>
<div class="icon-wrapper">
<div class="flower-bg" />
<div class="inner-icon">
<img src="@/assets/imgs/star-icon.svg" alt="star" class="star-img">
</div>
</div>
<div class="group-name-tag">
<span class="sun-icon"></span>
{{ item.name }}
</div>
</div>
</TransitionGroup>
</div>
</CompetitionLayout>
</template>
<style lang="scss" scoped>
$text-color: #5d4037;
$tag-bg: #d7ccc8;
.title-section {
margin-bottom: 40px;
.scroll-bg {
padding: 12px 50px;
background: url('@/assets/imgs/title-bg.svg') no-repeat center center;
background-size: cover;
.main-title {
font-size: 2.2rem;
color: #333;
margin: 0;
letter-spacing: 4px;
}
}
}
.groups-container {
width: 100%;
padding: 0 60px;
margin-top: 60px;
flex: 1;
display: flex;
justify-content: center;
align-items: flex-start;
overflow-y: auto;
}
.groups-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 20px;
width: 100%;
max-width: 1200px;
}
.group-item {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
transition: transform 0.3s ease;
width: calc(20% - 16px);
margin: 0;
margin-bottom: 30px;
&:hover {
transform: translateY(-5px);
}
.icon-wrapper {
width: 100px;
height: 100px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background-size: contain;
.star-img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.group-name-tag {
margin-top: 10px;
background: #e0f2f1;
padding: 4px 16px;
border-radius: 16px;
font-size: 1rem;
color: #5d4037;
font-weight: bold;
position: relative;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border: 2px solid #fff;
.sun-icon {
position: absolute;
right: -8px;
top: -8px;
font-size: 1rem;
}
&::before {
content: '🌸';
position: absolute;
left: -12px;
bottom: -4px;
font-size: 0.9rem;
}
}
}
// Transitions
.fade-slide-down-enter-active,
.fade-slide-down-leave-active {
transition: all 0.8s ease;
}
.fade-slide-down-enter-from,
.fade-slide-down-leave-to {
opacity: 0;
transform: translateY(-30px);
}
.list-anim-enter-active,
.list-anim-leave-active {
transition: all 0.5s cubic-bezier(0.5, 0, 0.25, 1);
transition-delay: var(--delay);
}
.list-anim-enter-from,
.list-anim-leave-to {
opacity: 0;
transform: scale(0.5);
}
/* Responsive styles auto-handled by flex-wrap */
</style>

View File

@ -1,46 +1,278 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useAppStore } from '@/store/modules/app'
import CardData from './modules/card-data.vue'
import CreativityBanner from './modules/creativity-banner.vue'
import HeaderBanner from './modules/header-banner.vue'
import LineChart from './modules/line-chart.vue'
import PieChart from './modules/pie-chart.vue'
import ProjectNews from './modules/project-news.vue'
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
const appStore = useAppStore()
const router = useRouter()
const gap = computed(() => (appStore.isMobile ? 0 : 16))
interface EventItem {
id: number
title: string
subTitle?: string
icon: string
status: 'active' | 'pending' | 'finished'
}
const events = ref<EventItem[]>([
{ id: 1, title: '第九届阅读之星', subTitle: '第一轮', icon: 'activity', status: 'active' },
{ id: 2, title: '第九届阅读之星', subTitle: '第二轮', icon: 'cast', status: 'pending' },
{ id: 3, title: '第一届', subTitle: '星际知识大赛', icon: 'chrome', status: 'active' },
{ id: 4, title: '第九届友谊赛', subTitle: '', icon: 'heart', status: 'finished' },
])
function handleEnter(_id: number) {
// router.push(`/competition/detail/${id}`);
router.push('/groups')
}
function handleBack() {
// console.log('back')
}
function handleNext() {
// 首页的下一步操作,可以根据需求实现
router.push('/groups')
}
const showContent = ref(false)
onMounted(() => {
setTimeout(() => {
showContent.value = true
}, 100)
})
</script>
<template>
<NSpace vertical :size="16">
<NAlert :title="$t('common.tip')" type="warning">
{{ $t('page.home.branchDesc') }}
</NAlert>
<HeaderBanner />
<CardData />
<NGrid :x-gap="gap" :y-gap="16" responsive="screen" item-responsive>
<NGi span="24 s:24 m:14">
<NCard :bordered="false" class="card-wrapper">
<LineChart />
</NCard>
</NGi>
<NGi span="24 s:24 m:10">
<NCard :bordered="false" class="card-wrapper">
<PieChart />
</NCard>
</NGi>
</NGrid>
<NGrid :x-gap="gap" :y-gap="16" responsive="screen" item-responsive>
<NGi span="24 s:24 m:14">
<ProjectNews />
</NGi>
<NGi span="24 s:24 m:10">
<CreativityBanner />
</NGi>
</NGrid>
</NSpace>
<CompetitionLayout
:show-back="false"
:show-next="false"
@back="handleBack"
@next="handleNext"
>
<!-- Title Section -->
<Transition name="fade-slide-down" appear>
<div v-if="showContent" class="title-section">
<div class="scroll-bg">
<h1 class="main-title">
赛事总览
</h1>
</div>
</div>
</Transition>
<!-- Cards Section -->
<div class="cards-container">
<TransitionGroup name="list-anim" tag="div" class="cards-wrapper">
<div
v-for="(item, index) in events"
v-show="showContent"
:key="item.id"
class="event-card"
:style="{ '--delay': `${index * 0.1}s` }"
@click="handleEnter(item.id)"
>
<div class="card-top-bar" />
<div class="card-body">
<div class="icon-wrapper">
<SvgIcon :local-icon="item.icon" class="event-icon" />
</div>
<div class="text-content">
<h3 class="event-title">
{{ item.title }}
</h3>
<p v-if="item.subTitle" class="event-subtitle">
{{ item.subTitle }}
</p>
</div>
</div>
<div class="tassel" />
</div>
</TransitionGroup>
</div>
</CompetitionLayout>
</template>
<style scoped></style>
<style lang="scss" scoped>
// Variables - 部分特定变量保留
$primary-color: #4db6ac;
$secondary-color: #333333;
$text-color: #004d40;
$card-bg: #b2dfdb;
// 注意:通用布局样式已移至 CompetitionLayout 组件
// 这里只保留页面特定的内容样式
.title-section {
margin-bottom: 60px;
.scroll-bg {
padding: 15px 60px;
position: relative;
background: url('@/assets/imgs/title-bg.svg') no-repeat center center;
background-size: cover;
.main-title {
font-size: 2.5rem;
color: $text-color;
margin: 0;
letter-spacing: 4px;
}
}
}
.cards-container {
width: 100%;
padding: 0 40px;
margin-top: 115px;
}
.cards-wrapper {
display: flex;
justify-content: center;
gap: 40px;
flex-wrap: wrap;
}
.event-card {
width: 340px;
height: 480px;
background: url('@/assets/imgs/event-card-bg.svg') no-repeat center center;
border-radius: 8px 8px 40px 40px;
position: relative;
cursor: pointer;
transition:
transform 0.3s ease,
box-shadow 0.3s ease;
display: flex;
flex-direction: column;
align-items: center;
.card-body {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
text-align: center;
}
.icon-wrapper {
width: 100px;
height: 100px;
background: rgba(255, 255, 255, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 30px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05);
.event-icon {
font-size: 48px;
color: $secondary-color;
}
}
.text-content {
.event-title {
font-size: 1.4rem;
font-weight: bold;
color: $text-color;
margin-bottom: 8px;
line-height: 1.4;
}
.event-subtitle {
font-size: 1.1rem;
color: lighten($text-color, 15%);
}
}
&:hover {
transform: translateY(-10px);
.icon-wrapper {
transform: scale(1.1) rotate(5deg);
transition: transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
}
}
// Transitions
.fade-slide-down-enter-active,
.fade-slide-down-leave-active {
transition: all 0.8s ease;
}
.fade-slide-down-enter-from,
.fade-slide-down-leave-to {
opacity: 0;
transform: translateY(-30px);
}
.list-anim-enter-active,
.list-anim-leave-active {
transition: all 0.6s cubic-bezier(0.5, 0, 0.25, 1);
transition-delay: var(--delay);
}
.list-anim-enter-from,
.list-anim-leave-to {
opacity: 0;
transform: translateY(50px);
}
// Responsive
@media (max-width: 1024px) {
.cards-wrapper {
gap: 20px;
}
.event-card {
width: 220px;
height: 320px;
}
}
@media (max-width: 768px) {
.cards-wrapper {
flex-direction: column;
align-items: center;
}
.event-card {
width: 90%;
height: 120px;
flex-direction: row;
border-radius: 8px;
.card-top-bar {
width: 16px;
height: 110%;
top: -5%;
left: -8px;
}
.tassel {
display: none;
}
.card-body {
flex-direction: row;
text-align: left;
padding: 10px 20px;
}
.icon-wrapper {
width: 60px;
height: 60px;
margin-bottom: 0;
margin-right: 20px;
.event-icon {
font-size: 32px;
}
}
}
}
</style>

View File

@ -5,11 +5,13 @@ import {
NFormItem,
NInput,
NModal,
NSelect,
useDialog,
useMessage,
} from 'naive-ui'
import { onMounted, ref, watch } from 'vue'
import SvgIcon from '@/components/custom/svg-icon.vue'
import { QuestionType } from '@/enum/business'
import { useDict } from '@/hooks/business/useDict'
import { fetchAddQuestion, fetchDeleteQuestion, fetchGetQuestionListAll, fetchUpdateQuestion } from '@/service/api/question'
@ -27,6 +29,7 @@ interface CategoryItem {
Id: number
Name: string
QuestionContent: string
QuestionType: QuestionType
}
// Mock Data - 模拟分类列表数据
@ -35,7 +38,7 @@ const categoryList = ref<CategoryItem[]>([])
const selectedKey = ref<number | null>(null)
const showCategoryModal = ref(false)
const categoryForm = ref({ questionContent: '', name: '', Id: 0, questionType: 'SingleChoice' })
const categoryForm = ref({ questionContent: '', name: '', Id: 0, questionType: QuestionType.Text })
const categoryOperation = ref<'add' | 'edit'>('add')
const currentOperationNode = ref<CategoryItem | null>(null)
@ -63,6 +66,7 @@ function handleAddCategory() {
categoryOperation.value = 'add'
categoryForm.value.questionContent = ''
categoryForm.value.name = ''
categoryForm.value.questionType = QuestionType.Text
currentOperationNode.value = null
showCategoryModal.value = true
}
@ -71,6 +75,7 @@ function handleEditCategory(item: CategoryItem) {
categoryOperation.value = 'edit'
categoryForm.value.name = item.Name
categoryForm.value.questionContent = item.QuestionContent
categoryForm.value.questionType = item.QuestionType
currentOperationNode.value = item
showCategoryModal.value = true
}
@ -116,6 +121,7 @@ async function submitCategory() {
questionContent: categoryForm.value.questionContent,
name: categoryForm.value.name,
id: 0,
questionType: categoryForm.value.questionType,
})
if (error) {
message.error('添加分类失败')
@ -129,6 +135,7 @@ async function submitCategory() {
questionContent: categoryForm.value.questionContent,
name: categoryForm.value.name,
id: currentOperationNode.value.Id,
questionType: categoryForm.value.questionType,
})
if (error) {
message.error('更新分类失败')

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { FormInst, UploadCustomRequestOptions, UploadFileInfo } from 'naive-ui'
import Clipboard from 'clipboard'
import {
NBreadcrumb,
NBreadcrumbItem,
@ -15,23 +16,60 @@ import {
NPagination,
NRadio,
NRadioGroup,
NSelect,
NTag,
NUpload,
useDialog,
useMessage,
} from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { pinyin } from 'pinyin-pro'
import { computed, onUnmounted, ref, watch } from 'vue'
import WangEditor from '@/components/common/wang-editor.vue'
import SvgIcon from '@/components/custom/svg-icon.vue'
import { questionTypeOptions } from '@/constants/business'
import { QuestionType } from '@/enum/business'
import { fetchAddQuestionLibrary, fetchDeleteQuestionLibrary, fetchGetQuestionLibraryListAll, fetchUpdateQuestionLibrary } from '@/service/api/question'
const props = defineProps<{
currentCategory: any
}>()
const message = useMessage()
const dialog = useDialog()
// 拼音转换工具
const pinyinTool = ref({
input: '',
output: '',
})
// 监听输入变化自动转换
watch(() => pinyinTool.value.input, (val) => {
if (val) {
pinyinTool.value.output = pinyin(val)
}
else {
pinyinTool.value.output = ''
}
})
// 复制拼音
const copyBtnRef = ref<any>(null)
let clipboard: Clipboard | null = null
watch(copyBtnRef, (inst) => {
if (clipboard) {
clipboard.destroy()
clipboard = null
}
if (inst) {
const domEl = inst.$el || inst
clipboard = new Clipboard(domEl)
clipboard.on('success', () => {
window?.$message?.success('拼音已复制到剪贴板')
})
clipboard.on('error', () => {
window?.$message?.error('复制失败,请手动复制')
})
}
})
onUnmounted(() => {
clipboard?.destroy()
})
// 题目列表数据
const questionList = ref<any[]>([])
@ -65,7 +103,7 @@ const currentQuestionId = ref<number | null>(null)
const questionForm = ref({
name: '',
answer: '',
type: 0,
type: QuestionType.SingleChoice as QuestionType | number, // 题目类型
imageUrl: '',
IsGood: 0,
})
@ -77,7 +115,7 @@ const questionFormRef = ref<FormInst | null>(null)
const rules = {
name: [{ required: true, message: '请输入题目正文内容', trigger: ['blur'] }],
answer: [{ required: true, message: '请输入题目答案', trigger: ['blur'] }],
type: [{ required: true, message: '请选择题目类型', trigger: ['change'], type: 'number' as const }],
type: [{ required: true, message: '请选择题目类型', trigger: ['change'] }],
}
const hasCategory = computed(() => {
@ -111,8 +149,6 @@ async function fetchData(id: number) {
pageSizes: pagination.value.pageSize,
keyWords: searchText.value,
})
// eslint-disable-next-line no-console
console.log(data, 'data')
if (!error) {
questionList.value = data?.data?.QuestionListDetailList || []
pagination.value.itemCount = data?.data?.TotalNum || 0
@ -129,17 +165,31 @@ function handleSearch() {
function handleAddQuestion() {
if (!props.currentCategory) {
message.warning('请先选择一个分类')
window?.$message?.warning('请先选择一个分类')
return
}
questionOperation.value = 'add'
currentQuestionId.value = null
questionForm.value = { name: '', answer: '', type: 0, imageUrl: '', IsGood: 0 }
questionForm.value = {
name: '',
answer: '',
type: props.currentCategory?.QuestionType || QuestionType.SingleChoice,
imageUrl: '',
IsGood: 0,
}
fileList.value = []
showQuestionModal.value = true
pinyinTool.value.input = ''
pinyinTool.value.output = ''
}
/**
* 处理编辑题目
* @param id 题目ID
*/
function handleEditQuestion(id: number) {
pinyinTool.value.input = ''
pinyinTool.value.output = ''
const question = questionList.value.find(q => q.Id === id)
if (question) {
questionOperation.value = 'edit'
@ -165,7 +215,7 @@ function handleEditQuestion(id: number) {
}
function handleDeleteQuestion(id: number) {
dialog.warning({
window?.$dialog?.warning({
title: '警告',
content: '确定要删除这道题目吗?此操作无法撤销。',
positiveText: '确定',
@ -173,13 +223,13 @@ function handleDeleteQuestion(id: number) {
onPositiveClick: async () => {
const { error } = await fetchDeleteQuestionLibrary([id])
if (!error) {
message.success('删除成功')
window?.$message?.success('删除成功')
if (props.currentCategory?.Id) {
fetchData(props.currentCategory.Id)
}
}
else {
message.error('删除失败')
window?.$message?.error('删除失败')
}
},
})
@ -188,9 +238,11 @@ function handleDeleteQuestion(id: number) {
async function submitQuestion() {
const valid = await questionFormRef.value?.validate()
if (!valid) {
message.error('请填写完整信息')
return
if (!questionForm.value.name) {
if (!valid) {
window?.$message?.error('请填写完整信息')
return
}
}
const commonParams = {
@ -203,18 +255,18 @@ async function submitQuestion() {
}
let fileToUpload: File | null = null
if (questionForm.value.type === 1) { // 图片类型 (1: 图片, 0: 文字 - based on previous fixes)
if (questionForm.value.type === QuestionType.Image) { // 图片类型
if (fileList.value.length > 0 && fileList.value[0].file) {
fileToUpload = fileList.value[0].file
}
else if (questionOperation.value === 'add' && fileList.value.length === 0) {
message.error('请上传图片')
return
}
else if (questionOperation.value === 'edit' && fileList.value.length === 0) {
message.error('请上传图片')
return
}
// else if (questionOperation.value === 'add' && fileList.value.length === 0) {
// window?.$message?.error('请上传图片')
// return
// }
// else if (questionOperation.value === 'edit' && fileList.value.length === 0) {
// window?.$message?.error('请上传图片')
// return
// }
}
if (questionOperation.value === 'add') {
@ -223,12 +275,12 @@ async function submitQuestion() {
id: 0,
}, fileToUpload)
if (!error) {
message.success('题目添加成功')
window?.$message?.success('题目添加成功')
showQuestionModal.value = false
fetchData(props.currentCategory.Id)
}
else {
message.error('添加失败')
window?.$message?.error('添加失败')
}
}
else if (questionOperation.value === 'edit' && currentQuestionId.value) {
@ -237,12 +289,12 @@ async function submitQuestion() {
id: currentQuestionId.value,
}, fileToUpload)
if (!error) {
message.success('题目修改成功')
window?.$message?.success('题目修改成功')
showQuestionModal.value = false
fetchData(props.currentCategory.Id)
}
else {
message.error('修改失败')
window?.$message?.error('修改失败')
}
}
}
@ -320,9 +372,7 @@ async function submitQuestion() {
<!-- <span class="text-xs text-gray-400 font-mono">{{ q.score }} PTS / {{ q.time }} S</span> -->
</template>
<div class="py-2 text-base text-gray-700 font-medium">
{{ q.Name }}
</div>
<div class="py-2 text-base text-gray-700 font-medium" v-html="q.Name" />
<!-- 图片展示 -->
<div v-if="q.ImageUrl" class="mb-2">
@ -373,11 +423,27 @@ async function submitQuestion() {
<!-- Add Question Drawer -->
<NDrawer v-model:show="showQuestionModal" :width="800">
<NDrawerContent :title="questionOperation === 'edit' ? '编辑题目详情' : '新增题目详情'">
<div class="mb-4">
<NCard size="small" title="🛠️ 拼音助手 (输入汉字获取带音标拼音)" class="bg-gray-50/50">
<div class="flex gap-2">
<NInput v-model:value="pinyinTool.input" placeholder="输入汉字..." class="flex-1" />
<div class="flex flex-1 items-center justify-between border border-gray-200 rounded bg-white px-3 py-1">
<span class="text-gray-600">{{ pinyinTool.output || '拼音结果将显示在这里' }}</span>
<textarea id="pinyinCopyTarget" v-model="pinyinTool.output" class="absolute opacity-0 -z-1" />
<div v-if="pinyinTool.output" ref="copyBtnRef" data-clipboard-target="#pinyinCopyTarget">
<NButton size="tiny" type="primary" secondary>
复制
</NButton>
</div>
</div>
</div>
</NCard>
</div>
<NForm ref="questionFormRef" label-placement="top" :rules="rules" :model="questionForm" size="small">
<NFormItem label="题目正文内容" path="name">
<div class="w-full overflow-hidden border border-gray-200 rounded-lg">
<!-- <RestBasicEditor v-model="questionForm.name" /> -->
<NInput v-model:value="questionForm.name" type="textarea" class="h-64 w-full" />
<WangEditor v-model="questionForm.name" placeholder="请输入题目内容..." height="300px" />
</div>
</NFormItem>
@ -391,9 +457,9 @@ async function submitQuestion() {
</div> -->
<!-- 类型 -->
<NFormItem label="类型" path="type">
<NSelect v-model:value="questionForm.type" class="w-full" :options="questionTypeOptions" />
</NFormItem>
<!-- <NFormItem label="类型" path="type">
<NSelect v-model:value="questionForm.type" class="w-full" :options="questionTypeOptionsDict" />
</NFormItem> -->
<!-- 是否优先使用 -->
<NFormItem label="是否优先使用" path="IsGood">
@ -406,9 +472,8 @@ async function submitQuestion() {
</NRadio>
</NRadioGroup>
</NFormItem>
<!-- 上传图片 -->
<NFormItem v-if="questionForm.type === 1" label="上传图片">
<NFormItem v-if="questionForm.type === QuestionType.Image" label="上传图片">
<NUpload
v-model:file-list="fileList" accept="image/*" :max="1" list-type="image-card"
:custom-request="customRequest" @change="handleUploadChange"
@ -435,3 +500,13 @@ async function submitQuestion() {
</NDrawer>
</div>
</template>
<style lang="scss" scoped>
:deep(.n-card__content) {
img {
height: 100px !important ;
width: 100px !important;
object-fit: contain;
}
}
</style>

View File

@ -0,0 +1,253 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
const router = useRouter()
interface TeamItem {
id: number
name: string
icon: string
}
const teams = ref<TeamItem[]>([
{ id: 1, name: '1号代表队', icon: 'star' },
{ id: 2, name: '2号代表队', icon: 'star' },
{ id: 3, name: '3号代表队', icon: 'star' },
{ id: 4, name: '4号代表队', icon: 'star' },
])
const showContent = ref(false)
function handleBack() {
router.push('/groups')
}
function handleNext() {
// 进入用户主路由
router.push('/user')
}
onMounted(() => {
setTimeout(() => {
showContent.value = true
}, 100)
})
</script>
<template>
<CompetitionLayout :show-back="true" :show-next="false" @back="handleBack" @next="handleNext">
<!-- Title Section -->
<Transition name="fade-slide-down" appear>
<div v-if="showContent" class="title-section">
<div class="scroll-bg">
<h1 class="main-title">
队伍名单
</h1>
</div>
</div>
</Transition>
<!-- Scroll Content -->
<div class="scroll-container">
<div class="scroll-body">
<div class="scroll-left-decor" />
<div class="scroll-content">
<TransitionGroup name="list-anim" tag="div" class="teams-grid">
<div
v-for="(item, index) in teams" v-show="showContent" :key="item.id" class="team-item"
:style="{ '--delay': `${index * 0.1}s` }"
>
<div class="icon-wrapper">
<div class="star-shape">
<div class="star-face">
<!-- 简单的表情模拟 -->
<div class="eyes">
<div class="eye" />
<div class="eye" />
</div>
<div class="mouth" />
</div>
</div>
</div>
<div class="team-name">
{{ item.name }}
</div>
</div>
</TransitionGroup>
</div>
<div class="scroll-right-decor" />
</div>
</div>
</CompetitionLayout>
</template>
<style lang="scss" scoped>
.title-section {
position: absolute;
top: 100px; // 调整位置,可能是在卷轴上方或者左侧
left: 50px; // 假设竖排标题
z-index: 10;
.scroll-bg {
width: 60px;
padding: 20px 10px;
background: #00695c; // 假设一个深色背景
border-radius: 4px;
color: #fff;
box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.3);
.main-title {
font-size: 1.5rem;
margin: 0;
writing-mode: vertical-rl; // 竖排文字
letter-spacing: 4px;
text-align: center;
}
}
}
// 覆盖默认样式,因为这个页面设计比较特殊(卷轴)
.competition-layout :deep(.main-content) {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.scroll-container {
width: 80%;
height: 70%;
position: relative;
display: flex;
justify-content: center;
align-items: center;
border: 1px solid red;
}
.scroll-body {
width: 100%;
height: 781px;
border-top: 10px solid #d7ccc8;
border-bottom: 10px solid #d7ccc8;
position: relative;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
background: url('@/assets/imgs/team-bg.svg') no-repeat center center;
background-size: 100% 100%;
&::before {
left: -15px;
}
&::after {
right: -15px;
}
}
.scroll-content {
width: 80%;
height: 80%;
display: flex;
align-items: center;
justify-content: center;
}
.teams-grid {
display: flex;
gap: 60px;
justify-content: center;
}
.team-item {
display: flex;
flex-direction: column;
align-items: center;
.icon-wrapper {
width: 140px;
height: 140px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
// background: url('@/assets/imgs/team-icon-bg.svg') no-repeat center center;
background-size: contain;
margin-bottom: 20px;
// 模拟星星形状
.star-shape {
width: 100px;
height: 100px;
background: linear-gradient(135deg, #42a5f5, #ab47bc);
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
.star-face {
width: 60%;
height: 60%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.eyes {
display: flex;
gap: 15px;
margin-bottom: 5px;
.eye {
width: 8px;
height: 12px;
background: #fff;
border-radius: 50%;
}
}
.mouth {
width: 20px;
height: 10px;
border-bottom: 3px solid #fff;
border-radius: 50%;
}
}
}
}
.team-name {
font-size: 1.2rem;
color: #333;
font-weight: bold;
}
}
// Transitions
.fade-slide-down-enter-active,
.fade-slide-down-leave-active {
transition: all 0.8s ease;
}
.fade-slide-down-enter-from,
.fade-slide-down-leave-to {
opacity: 0;
transform: translateX(-30px); // 竖排标题从左侧进入
}
.list-anim-enter-active,
.list-anim-leave-active {
transition: all 0.5s cubic-bezier(0.5, 0, 0.25, 1);
transition-delay: var(--delay);
}
.list-anim-enter-from,
.list-anim-leave-to {
opacity: 0;
transform: translateY(30px);
}
</style>

View File

@ -0,0 +1,179 @@
import type { CompetitionData } from '../types'
import img from '@/assets/imgs/q5-img.png'
/**
* 模拟数据
* 真实后端数据可能是:
* 活动详情接口传活动id 获取 流程nodes
* 每次可能只返回一个n1前端先展示n1用户点击“抽题”后再请求n2。其中questions 可能不是在n1就返回而是点击抽提时候 传nodeId 去随机获取后前端保存
* 1. 包含 3 个节点N1, N2, N3
* 2. 每个节点包含 2 个批次(汉字听一听, 汉字加一加)
* 3. 每个批次包含 2 个问题Q1, Q2, Q3, Q4, Q5
*/
export const mockData: CompetitionData = {
activityInfo: {
name: '星·辞海遨游',
},
nodes: [
{
nodeId: 'N1',
roundTitle: '第一轮',
moduleName: '汉字听一听',
batchName: '根据提示写汉字',
description: '请根据汉语拼音提示书写正确的汉字时间为15秒。',
uiTemplate: 'TEMPLATE_DICTATION_HINT',
config: { timeLimit: 5, questionCount: 2, scorePerQuestion: 1 },
questions: [
{
id: 'Q1',
content: { pinyin: 'jiū,表示小鸟的叫声', hint: '表示小鸟的叫声' },
answer: ['啾'],
},
{
id: 'Q2',
content: { pinyin: 'yāo', hint: '形容草木茂盛美丽' },
answer: ['夭'],
},
],
},
{
nodeId: 'N2',
roundTitle: '第二轮',
moduleName: '汉字听一听',
batchName: '同音字',
description: '诗词理解',
uiTemplate: 'TEMPLATE_DICTATION_HOMOPHONE',
config: { timeLimit: 5, questionCount: 2, scorePerQuestion: 1 },
questions: [
{
id: 'Q3',
content: { mainText: 'táng' },
answer: ['唐', '塘', '糖', '搪', '溏', '瑭', '鄌', '螗', '糖', '堂', '膛', '螳', '鄳', '樘', '镗', '棠', '饧'],
},
{
id: 'Q4',
content: { mainText: 'jù' },
answer: ['剧', '据', '巨', '拒', '聚', '炬', '距', '惧', '具', '俱', '沮', '咀', '矩', '锯'],
},
],
},
{
nodeId: 'N3',
roundTitle: '第二轮',
moduleName: '诗词理解',
batchName: '选择题',
description: '请选择正确的选项时间为30秒。',
uiTemplate: 'TEMPLATE_POETRY_MULTIPLE_CHOICE',
config: { timeLimit: 30, questionCount: 2, scorePerQuestion: 2 },
questions: [
{
id: 'Q3',
content: {
questionText: '《闻王昌龄左迁龙标遥有此寄》中,“左迁”的意思是()',
options: [
{ label: 'A', text: '贬官,降职', image: img },
{ label: 'B', text: '搬家', image: img },
{ label: 'C', text: '一路向西游玩', image: img },
],
},
answer: ['A'],
},
{
id: 'Q4',
content: {
questionText: '《静夜思》中,“举头望明月”的下一句是()',
options: [
{ label: 'A', text: '低头思故乡', image: img },
{ label: 'B', text: '疑是地上霜', image: img },
],
},
answer: ['A'],
},
],
},
{
nodeId: 'N4',
roundTitle: '第三轮',
moduleName: '汉字加一加',
batchName: '部件组合',
description: '请写出含有指定部件的汉字时间为60秒。',
uiTemplate: 'TEMPLATE_COMPONENT_ADD',
config: { timeLimit: 5, questionCount: 1, scorePerQuestion: 1 },
questions: [
{
id: 'Q5',
content: { component: '车' },
answer: ['辆', '轿', '转', '软', '轮', '辐', '输', '轨', '轩', '轻', '轶', '辄', '轴', '轧', '轼', '辊', '辕', '辙', '辍', '辗', '轸', '轭', '辚', '辖', '较', '辘', '轫', '轵', '辋', '轲', '轺', '臻', '软', '辌', '轷', '轻', '辁', '辒', '蚺', '韫', '轱', '辂', '轻', '轹', '辅', '舻', '辐', '斩', '连', '阵', '军', '轰', '库', '辈', '载', '辈', '晕', '辔'],
},
],
},
{
nodeId: 'N5',
roundTitle: '第四轮',
moduleName: '词语听写',
batchName: '拼音写词',
description: '请根据汉语拼音提示书写正确的词语时间为30秒。',
uiTemplate: 'TEMPLATE_WORD_DICTATION',
config: { timeLimit: 5, questionCount: 2, scorePerQuestion: 2 },
questions: [
{
id: 'Q6',
content: { pinyin: 'zhì rè', hint: '形容温度极高' },
answer: ['炙热'],
},
{
id: 'Q7',
content: { pinyin: 'hào hàn', hint: '形容广大繁多' },
answer: ['浩瀚'],
},
],
},
{
nodeId: 'N6',
roundTitle: '第五轮',
moduleName: '成语写一写',
batchName: '文字要求',
description: '请根据要求书写正确的成语时间为60秒。',
uiTemplate: 'TEMPLATE_IDIOM_TEXT_REQ',
config: { timeLimit: 5, questionCount: 1, scorePerQuestion: 1 },
questions: [
{
id: 'Q8',
content: { requirement: '请写出含有反义字的四字成语。' },
answer: ['七上八下', '前因后果', '大同小异', '东张西望', '左顾右盼', '南辕北辙', '深入浅出', '出生入死', '死去活来', '天翻地覆', '黑白分明'],
},
],
},
{
nodeId: 'N7',
roundTitle: '第六轮',
moduleName: '成语写一写',
batchName: '看图写成语',
description: '请根据图片书写正确的成语时间为30秒。',
uiTemplate: 'TEMPLATE_IDIOM_IMAGE',
config: { timeLimit: 30, questionCount: 2, scorePerQuestion: 2 },
questions: [
{
id: 'Q9',
content: { hint: '比喻事情已定', image: img },
answer: ['板上钉钉'],
},
{
id: 'Q10',
content: { hint: '比喻事情已定', image: img },
answer: ['板上钉钉'],
},
],
},
],
}
export const uiTemplatesMapping = {
TEMPLATE_DICTATION_HINT: '汉字听写-提示',
TEMPLATE_DICTATION_HOMOPHONE: '汉字听写-同音',
TEMPLATE_COMPONENT_ADD: '汉字加一加',
TEMPLATE_WORD_DICTATION: '词语听写',
TEMPLATE_IDIOM_TEXT_REQ: '成语-文字要求',
TEMPLATE_IDIOM_IMAGE: '成语-看图',
TEMPLATE_POETRY_MULTIPLE_CHOICE: '诗词理解-选择题',
}

View File

@ -0,0 +1,223 @@
import type { CompetitionData } from '../types'
import img from '@/assets/imgs/q5-img.png'
/**
* 模拟数据
* 真实后端数据可能是:
* 活动详情接口传活动id 获取 流程nodes
* 每次可能只返回一个n1前端先展示n1用户点击“抽题”后再请求n2。其中questions 可能不是在n1就返回而是点击抽提时候 传nodeId 去随机获取后前端保存
* 1. 包含 3 个节点N1, N2, N3
* 2. 每个节点包含 2 个批次(汉字听一听, 汉字加一加)
* 3. 每个批次包含 2 个问题Q1, Q2, Q3, Q4, Q5
*/
export const mockData: CompetitionData = {
activityInfo: {
name: '星·辞海遨游',
},
nodes: [
{
nodeId: 'N3',
roundTitle: '第二轮',
moduleName: '诗词理解',
batchName: '选择题',
description: '请选择正确的选项时间为30秒。',
uiTemplate: 'TEMPLATE_POETRY_MULTIPLE_CHOICE',
config: { timeLimit: 300, questionCount: 2, scorePerQuestion: 2 },
questions: [
{
id: 'Q3',
content: {
title: '《闻王昌龄左迁龙标遥有此寄》中,“左迁”的意思是()',
options: [
{ label: 'A', text: '贬官,降职', image: img },
{ label: 'B', text: '搬家', image: img },
{ label: 'C', text: '一路向西游玩', image: img },
],
},
answer: ['A'],
},
{
id: 'Q4',
content: {
title: '《静夜思》中,“举头望明月”的下一句是()',
options: [
{ label: 'A', text: '低头思故乡', image: img },
{ label: 'B', text: '疑是地上霜', image: img },
],
},
answer: ['A'],
},
],
},
{
nodeId: 'N1',
roundTitle: '第一轮',
moduleName: '汉字听一听',
batchName: '根据提示写汉字',
description: '请根据汉语拼音提示书写正确的汉字时间为15秒。',
uiTemplate: 'TEMPLATE_DICTATION_HINT',
config: { timeLimit: 5, questionCount: 2, scorePerQuestion: 1 },
questions: [
{
id: 'Q1',
content: {
title: 'jiū,表示小鸟的叫声',
meta: { hint: '表示小鸟的叫声' },
},
answer: ['啾'],
},
{
id: 'Q2',
content: {
title: 'yāo',
meta: { hint: '形容草木茂盛美丽' },
},
answer: ['夭'],
},
],
},
{
nodeId: 'N2',
roundTitle: '第二轮',
moduleName: '汉字听一听',
batchName: '同音字',
description: '诗词理解',
uiTemplate: 'TEMPLATE_DICTATION_HOMOPHONE',
config: { timeLimit: 5, questionCount: 2, scorePerQuestion: 1 },
questions: [
{
id: 'Q3',
content: { title: 'táng' },
answer: ['唐', '塘', '糖', '搪', '溏', '瑭', '鄌', '螗', '糖', '堂', '膛', '螳', '鄳', '樘', '镗', '棠', '饧'],
},
{
id: 'Q4',
content: { title: 'jù' },
answer: ['剧', '据', '巨', '拒', '聚', '炬', '距', '惧', '具', '俱', '沮', '咀', '矩', '锯'],
},
],
},
{
nodeId: 'N4',
roundTitle: '第三轮',
moduleName: '汉字加一加',
batchName: '部件组合',
description: '请写出含有指定部件的汉字时间为60秒。',
uiTemplate: 'TEMPLATE_COMPONENT_ADD',
config: { timeLimit: 5, questionCount: 1, scorePerQuestion: 1 },
questions: [
{
id: 'Q5',
content: { title: '车' },
answer: ['辆', '轿', '转', '软', '轮', '辐', '输', '轨', '轩', '轻', '轶', '辄', '轴', '轧', '轼', '辊', '辕', '辙', '辍', '辗', '轸', '轭', '辚', '辖', '较', '辘', '轫', '轵', '辋', '轲', '轺', '臻', '软', '辌', '轷', '轻', '辁', '辒', '蚺', '韫', '轱', '辂', '轻', '轹', '辅', '舻', '辐', '斩', '连', '阵', '军', '轰', '库', '辈', '载', '辈', '晕', '辔'],
},
],
},
{
nodeId: 'N5',
roundTitle: '第四轮',
moduleName: '词语听写',
batchName: '拼音写词',
description: '请根据汉语拼音提示书写正确的词语时间为30秒。',
uiTemplate: 'TEMPLATE_WORD_DICTATION',
config: { timeLimit: 5, questionCount: 2, scorePerQuestion: 2 },
questions: [
{
id: 'Q6',
content: {
title: 'zhì rè',
meta: { hint: '形容温度极高' },
},
answer: ['炙热'],
},
{
id: 'Q7',
content: {
title: 'hào hàn',
meta: { hint: '形容广大繁多' },
},
answer: ['浩瀚'],
},
],
},
{
nodeId: 'N6',
roundTitle: '第五轮',
moduleName: '成语写一写',
batchName: '文字要求',
description: '请根据要求书写正确的成语时间为60秒。',
uiTemplate: 'TEMPLATE_IDIOM_TEXT_REQ',
config: { timeLimit: 5, questionCount: 1, scorePerQuestion: 1 },
questions: [
{
id: 'Q8',
content: { title: '请写出含有反义字的四字成语。' },
answer: ['七上八下', '前因后果', '大同小异', '东张西望', '左顾右盼', '南辕北辙', '深入浅出', '出生入死', '死去活来', '天翻地覆', '黑白分明'],
},
],
},
{
nodeId: 'N7',
roundTitle: '第六轮',
moduleName: '成语写一写',
batchName: '看图写成语',
description: '请根据图片书写正确的成语时间为30秒。',
uiTemplate: 'TEMPLATE_IDIOM_IMAGE',
config: { timeLimit: 30, questionCount: 2, scorePerQuestion: 2 },
questions: [
{
id: 'Q9',
content: {
title: '',
titleImage: img,
meta: { hint: '比喻事情已定' },
},
answer: ['板上钉钉'],
},
{
id: 'Q10',
content: {
title: '',
titleImage: img,
meta: { hint: '比喻事情已定' },
},
answer: ['板上钉钉'],
},
],
},
{
nodeId: 'N8',
roundTitle: '第七轮',
moduleName: '汉字辨析',
batchName: '选字填空',
description: '请选择正确的汉字填入括号中。',
uiTemplate: 'TEMPLATE_POETRY_MULTIPLE_CHOICE',
config: { timeLimit: 20, questionCount: 1, scorePerQuestion: 2 },
questions: [
{
id: 'Q11',
content: {
title: '下列哪个字是“没”的繁体字?',
options: [
{ label: 'A', text: '没', renderType: 'tianzige' },
{ label: 'B', text: '沒', renderType: 'tianzige' },
{ label: 'C', text: '殁', renderType: 'tianzige' },
],
},
answer: ['B'],
},
],
},
],
}
export const uiTemplatesMapping = {
TEMPLATE_DICTATION_HINT: '汉字听写-提示',
TEMPLATE_DICTATION_HOMOPHONE: '汉字听写-同音',
TEMPLATE_COMPONENT_ADD: '汉字加一加',
TEMPLATE_WORD_DICTATION: '词语听写',
TEMPLATE_IDIOM_TEXT_REQ: '成语-文字要求',
TEMPLATE_IDIOM_IMAGE: '成语-看图',
TEMPLATE_POETRY_MULTIPLE_CHOICE: '诗词理解-选择题',
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@ -0,0 +1,168 @@
<script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue'
import { mockData } from './data/mock' // 引入模拟数据
import AnswerAnalysisComp from './modules/AnswerAnalysisComp.vue'
import CoverComp from './modules/CoverComp.vue' // 封面组件
import GameComp from './modules/GameComp.vue'
import IntroComp from './modules/IntroComp.vue'
// 状态定义
type Status = 'COVER' | 'INTRO' | 'GAME' | 'ANSWER_ANALYSIS'
const status = ref<Status>('COVER')
const data = ref(mockData)
// 索引控制
const currentStep = ref(0) // Node 索引
const subStep = ref(0) // Question 索引
// 定时器控制
const timeLeft = ref(0)
let timerInterval: number | null = null
// --- 计算属性 ---
const currentNode = computed(() => data.value.nodes[currentStep.value])
const currentQuestion = computed(() => {
// 保护性代码:防止越界
const questions = currentNode.value.questions
return questions[subStep.value] || questions[0]
})
// --- 动作处理 ---
// 1. 开始比赛 -> 去第一个环节的介绍页
function handleStart() {
currentStep.value = 0
subStep.value = 0
status.value = 'INTRO'
}
// 2. 抽题 -> 去答题页,重置时间
function handleDraw() {
status.value = 'GAME'
timeLeft.value = currentNode.value.config.timeLimit
startTimer()
}
// 3. 定时器逻辑
function startTimer() {
if (timerInterval)
clearInterval(timerInterval)
timerInterval = window.setInterval(() => {
if (timeLeft.value > 0) {
timeLeft.value--
}
else {
// 时间到,清除定时器,进入答题图解页面
if (timerInterval)
clearInterval(timerInterval)
status.value = 'ANSWER_ANALYSIS'
}
}, 1000)
}
// 4. 下一题逻辑 (核心流程)
function handleToAnalysis() {
if (timerInterval)
clearInterval(timerInterval)
status.value = 'ANSWER_ANALYSIS'
}
function handleNext() {
if (timerInterval)
clearInterval(timerInterval)
const totalQuestionsInNode = currentNode.value.config.questionCount
// A. 如果当前环节还有题
if (subStep.value < totalQuestionsInNode - 1 && subStep.value < currentNode.value.questions.length - 1) {
subStep.value++
// 回到介绍页(抽题页),等待用户点击“抽题”再次开始
status.value = 'INTRO'
}
// B. 当前环节结束,去下一个环节
else {
if (currentStep.value < data.value.nodes.length - 1) {
currentStep.value++
subStep.value = 0
status.value = 'INTRO' // 回到介绍页
}
else {
// 全部结束
window.$message?.success('全场比赛结束!')
status.value = 'COVER'
currentStep.value = 0
subStep.value = 0
}
}
}
onUnmounted(() => {
if (timerInterval)
clearInterval(timerInterval)
})
</script>
<template>
<div class="user-app-container relative h-screen w-full overflow-hidden font-sans">
<!-- 背景图占位 (实际项目中应为 img background-image) -->
<div class="absolute inset-0 z-0 bg-blue-100/50">
<!-- 模拟云纹/山水背景 -->
<div class="absolute bottom-0 h-1/3 w-full from-teal-200/50 to-transparent bg-gradient-to-t" />
<div class="absolute top-0 h-1/4 w-full from-blue-200/50 to-transparent bg-gradient-to-b" />
</div>
<!-- 顶部左右信息 (固定) -->
<div class="absolute left-6 top-4 z-20 text-lg text-gray-700 font-medium tracking-wide">
教育学会
</div>
<div class="absolute right-6 top-4 z-20 text-lg text-gray-700 font-medium tracking-wide">
树人研究院
</div>
<!-- 主内容区域 -->
<div class="relative z-10 h-full w-full">
<!-- 状态 1: 封面 -->
<CoverComp
v-if="status === 'COVER'"
:title="data.activityInfo.name"
:nodes="data.nodes"
@start="handleStart"
/>
<!-- 状态 2: 介绍页 -->
<IntroComp
v-else-if="status === 'INTRO'"
:node="currentNode"
@back="status = 'COVER'"
@draw="handleDraw"
/>
<!-- 状态 3: 答题页 -->
<GameComp
v-else-if="status === 'GAME'"
:node="currentNode"
:question="currentQuestion"
:sub-step="subStep"
:time-left="timeLeft"
@back="status = 'INTRO'"
@next="handleToAnalysis"
/>
<!-- 状态 4: 答题图解页 -->
<AnswerAnalysisComp
v-else-if="status === 'ANSWER_ANALYSIS'"
:question-id="currentQuestion.id"
@back="status = 'GAME'"
@next="handleNext"
/>
</div>
</div>
</template>
<style scoped>
.user-app-container {
/* 基础背景色,防止图片加载失败时太突兀 */
background-color: #eef7ff;
}
</style>

View File

@ -0,0 +1,166 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{
questionId?: string
}>()
defineEmits(['back', 'next'])
const zoomedImage = ref<string | null>(null)
// Mock team data
const teams = ref([
{
id: 1,
name: '第一队',
writtenCount: 16,
correctCount: 13,
score: 13,
manualScore: null as number | null,
image: 'https://via.placeholder.com/300x400?text=Team+1+Answer', // Placeholder for now
},
{
id: 2,
name: '第二队',
writtenCount: 15,
correctCount: 12,
score: 12,
manualScore: null as number | null,
image: 'https://via.placeholder.com/300x400?text=Team+2+Answer',
},
{
id: 3,
name: '第三队',
writtenCount: 18,
correctCount: 15,
score: 15,
manualScore: null as number | null,
image: 'https://via.placeholder.com/300x400?text=Team+3+Answer',
},
{
id: 4,
name: '第四队',
writtenCount: 14,
correctCount: 10,
score: 10,
manualScore: null as number | null,
image: 'https://via.placeholder.com/300x400?text=Team+4+Answer',
},
])
function handleZoom(img: string) {
zoomedImage.value = img
}
</script>
<template>
<div class="relative h-full w-full flex flex-col items-center px-12 pt-28">
<!-- Top Bar -->
<div class="relative z-10 mb-6 w-full flex items-center justify-between">
<!-- Back Button -->
<button
class="border-2 border-red-400 rounded-full bg-white px-8 py-2 text-lg text-red-500 font-bold shadow-md transition hover:bg-red-50"
@click="$emit('back')"
>
返回
</button>
<!-- Title Scroll -->
<div class="absolute left-1/2 top-0 -translate-x-1/2">
<div class="relative h-20 min-w-[300px] flex items-center justify-center border-2 border-orange-200 rounded-lg from-orange-100 to-orange-50 bg-gradient-to-b px-12 shadow-lg">
<div class="absolute top-1/2 h-24 w-6 border-r-4 border-yellow-600 rounded-l-md bg-teal-700 shadow-md -left-3 -translate-y-1/2">
<div class="absolute left-1 top-2 h-2 w-full rounded-full bg-teal-600/50" />
<div class="absolute bottom-2 left-1 h-2 w-full rounded-full bg-teal-600/50" />
</div>
<div class="absolute top-1/2 h-24 w-6 border-l-4 border-yellow-600 rounded-r-md bg-teal-700 shadow-md -right-3 -translate-y-1/2">
<div class="absolute right-1 top-2 h-2 w-full rounded-full bg-teal-600/50" />
<div class="absolute bottom-2 right-1 h-2 w-full rounded-full bg-teal-600/50" />
</div>
<h1 class="text-3xl text-gray-800 font-bold tracking-widest font-serif">
答题图解
</h1>
</div>
</div>
<!-- Next Button -->
<button
class="border-2 border-red-400 rounded-full bg-white px-8 py-2 text-lg text-red-500 font-bold shadow-md transition hover:bg-red-50"
@click="$emit('next')"
>
下一题
</button>
</div>
<!-- Content -->
<div class="w-full flex flex-col flex-1 overflow-hidden">
<div class="mb-2 text-xl text-red-600 font-bold">
备注由AI模型评判
</div>
<div class="grid grid-cols-4 h-full gap-6 pb-8">
<div v-for="team in teams" :key="team.id" class="flex flex-col border-2 border-gray-200 rounded-xl bg-white/90 p-4 shadow-sm">
<div class="mb-2 text-xl text-gray-800 font-bold">
{{ team.name }}
</div>
<!-- Answer Image Area -->
<div class="relative mb-4 flex-1 cursor-zoom-in overflow-hidden border border-gray-300 rounded bg-gray-50" @click="handleZoom(team.image)">
<!-- Grid Background Simulation -->
<div class="pointer-events-none absolute inset-0 grid grid-cols-8 gap-px bg-gray-200 opacity-20">
<div v-for="n in 64" :key="n" class="bg-white" />
</div>
<!-- Simulated Handwriting/Content -->
<div class="absolute inset-0 flex items-center justify-center text-gray-400">
(点击放大查看详情)
</div>
</div>
<!-- Stats & Score -->
<div class="flex flex-col gap-2">
<div class="text-sm text-gray-700 font-medium">
写了数量{{ team.writtenCount }}正确{{ team.correctCount }}积分{{ team.score }}
</div>
<div class="flex items-center justify-between">
<span class="text-red-500 font-bold">评委当场评</span>
</div>
<div class="relative">
<input
v-model="team.manualScore"
type="number"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
placeholder="留个输入框,修改正确积分"
>
</div>
</div>
</div>
</div>
</div>
<!-- Zoom Modal -->
<div v-if="zoomedImage" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm" @click="zoomedImage = null">
<div class="relative max-h-[90vh] max-w-[90vw] p-4">
<img :src="zoomedImage" class="max-h-full max-w-full rounded bg-white shadow-2xl" alt="Zoomed Answer">
<div class="absolute bottom-4 left-1/2 text-sm text-white/80 -translate-x-1/2">
点击任意处关闭
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Custom Scrollbar for content if needed */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
</style>

View File

@ -0,0 +1,81 @@
<script setup lang="ts">
import type { ActivityNode } from '../types'
defineProps<{
title: string
nodes: ActivityNode[]
}>()
defineEmits(['start'])
</script>
<template>
<div class="h-full w-full flex flex-col items-center pt-24">
<!-- 标题卷轴 -->
<div class="relative mb-20">
<!-- 卷轴主体 -->
<div class="relative z-10 h-24 min-w-[400px] flex items-center justify-center border-2 border-orange-200 rounded-lg from-orange-100 to-orange-50 bg-gradient-to-b px-12 shadow-lg">
<div class="absolute top-1/2 h-28 w-8 border-r-4 border-yellow-600 rounded-l-md bg-teal-700 shadow-md -left-4 -translate-y-1/2">
<!-- 卷轴轴头 -->
<div class="absolute left-1 top-2 h-2 w-full rounded-full bg-teal-600/50" />
<div class="absolute bottom-2 left-1 h-2 w-full rounded-full bg-teal-600/50" />
</div>
<div class="absolute top-1/2 h-28 w-8 border-l-4 border-yellow-600 rounded-r-md bg-teal-700 shadow-md -right-4 -translate-y-1/2">
<!-- 卷轴轴头 -->
<div class="absolute right-1 top-2 h-2 w-full rounded-full bg-teal-600/50" />
<div class="absolute bottom-2 right-1 h-2 w-full rounded-full bg-teal-600/50" />
</div>
<!-- 飘带装饰 -->
<div class="absolute h-12 w-24 rotate-12 transform rounded-full bg-green-200/50 blur-xl -right-12 -top-6 -z-10" />
<div class="absolute h-12 w-24 transform rounded-full bg-green-200/50 blur-xl -bottom-6 -left-12 -z-10 -rotate-12" />
<h1 class="text-4xl text-gray-800 font-bold tracking-widest font-serif">
{{ title }}
</h1>
</div>
</div>
<!-- 卡片列表 -->
<div class="grid grid-cols-4 max-w-7xl w-full gap-8 px-12">
<div
v-for="(node) in nodes.slice(0, 4)"
:key="node.nodeId"
class="group relative cursor-default"
>
<!-- 卡片背景 (仿古风) -->
<div class="relative aspect-[3/4] w-full flex flex-col items-center justify-center overflow-hidden border-4 border-orange-200 rounded-2xl bg-orange-50/80 shadow-lg transition-transform hover:-translate-y-2">
<!-- 内部装饰圈 -->
<div class="absolute inset-4 flex items-center justify-center border-2 border-orange-300/50 rounded-xl border-dashed">
<!-- 云纹装饰 (CSS模拟) -->
<div class="absolute h-12 w-12 rounded-full bg-teal-100/30 blur-md -left-4 -top-4" />
<div class="absolute h-12 w-12 rounded-full bg-teal-100/30 blur-md -bottom-4 -right-4" />
</div>
<!-- 文字内容 -->
<div class="relative z-10 transform text-center -rotate-2">
<div class="text-3xl text-gray-800 font-bold leading-tight font-serif drop-shadow-sm">
<!-- 强制换行处理每两个字换行或根据长度 -->
<span class="block">{{ node.moduleName.slice(0, 2) }}</span>
<span class="block">{{ node.moduleName.slice(2) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 准备开始按钮 -->
<div class="fixed bottom-12 right-12">
<button
class="border-2 border-red-200 rounded-full from-red-400 to-red-500 bg-gradient-to-r px-10 py-3 text-xl text-white font-bold shadow-lg transition active:scale-95 hover:from-red-500 hover:to-red-600"
@click="$emit('start')"
>
准备开始
</button>
</div>
</div>
</template>
<style scoped>
/* 可以添加一些古风字体或特定样式 */
</style>

View File

@ -0,0 +1,148 @@
<script setup lang="ts">
import type { ActivityNode, Question } from '../types'
import { computed } from 'vue'
import { uiTemplatesMapping } from '../data/mock'
import QuestionRenderer from './QuestionRenderer.vue'
const props = defineProps<{
node: ActivityNode
question: Question
subStep: number
timeLeft: number
}>()
defineEmits(['back', 'next'])
const formattedTime = computed(() => {
const m = Math.floor(props.timeLeft / 60)
const s = props.timeLeft % 60
return `${m}:${s < 10 ? `0${s}` : s}`
})
// Mock chart data
const chartData = [
{ group: '第一组', total: 42, correct: 36 },
{ group: '第二组', total: 20, correct: 16 },
{ group: '第三组', total: 19, correct: 19 },
{ group: '第四组', total: 17, correct: 14 },
]
</script>
<template>
<div class="relative h-full w-full flex flex-col items-center px-12 pt-28">
<!-- 顶部栏 -->
<div class="relative z-10 mb-8 w-full flex items-center justify-between">
<!-- 返回按钮 -->
<button
class="border-2 border-red-400 rounded-full bg-white px-8 py-2 text-lg text-red-500 font-bold shadow-md transition hover:bg-red-50"
@click="$emit('back')"
>
返回
</button>
<!-- 标题卷轴 -->
<div class="absolute left-1/2 top-0 -translate-x-1/2">
<div
class="relative h-20 min-w-[300px] flex items-center justify-center border-2 border-orange-200 rounded-lg from-orange-100 to-orange-50 bg-gradient-to-b px-12 shadow-lg"
>
<div
class="absolute top-1/2 h-24 w-6 border-r-4 border-yellow-600 rounded-l-md bg-teal-700 shadow-md -left-3 -translate-y-1/2"
>
<div class="absolute left-1 top-2 h-2 w-full rounded-full bg-teal-600/50" />
<div class="absolute bottom-2 left-1 h-2 w-full rounded-full bg-teal-600/50" />
</div>
<div
class="absolute top-1/2 h-24 w-6 border-l-4 border-yellow-600 rounded-r-md bg-teal-700 shadow-md -right-3 -translate-y-1/2"
>
<div class="absolute right-1 top-2 h-2 w-full rounded-full bg-teal-600/50" />
<div class="absolute bottom-2 right-1 h-2 w-full rounded-full bg-teal-600/50" />
</div>
<h1 class="text-3xl text-gray-800 font-bold tracking-widest font-serif">
{{ node.moduleName }}
<span class="text-sm text-red-500 font-bold">{{ uiTemplatesMapping[node.uiTemplate] }}</span>
</h1>
</div>
</div>
<!-- 倒计时 -->
<div class="border-2 border-red-400 rounded-full bg-white px-6 py-2 shadow-md">
<span class="text-xl text-red-500 font-bold">倒计时 {{ formattedTime }}</span>
</div>
</div>
<!-- 题目说明 -->
<div class="m-14 text-2xl text-gray-800 font-medium">
{{ node.description }}
</div>
<!-- 题目内容区域 -->
<div class="flex-2 mb-4 w-full flex items-center justify-center">
<QuestionRenderer
:template="node.uiTemplate" :content="question.content"
class="origin-center scale-150 transform"
/>
</div>
<!-- 隐藏的 Next 按钮 (点击题目区域或键盘操作这里为了演示保留一个透明层或仅通过逻辑触发或者暂时放一个不易察觉的按钮用于测试) -->
<div class="absolute inset-0 z-0" @click="$emit('next')" /> <!-- 点击背景切换下一题方便演示 -->
<!-- 底部图表区域 -->
<div
class="relative z-20 mb-8 h-64 max-w-5xl w-full flex flex-col border-2 border-blue-300 rounded-xl bg-white/90 p-4 shadow-lg backdrop-blur"
>
<!-- 图表标题栏 -->
<div 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>
</div>
<div class="flex gap-6 text-sm">
<div class="flex items-center gap-2">
<div class="h-3 w-3 rounded-full bg-blue-400" />
<span class="text-gray-600">答题数量</span>
</div>
<div class="flex items-center gap-2">
<div class="h-3 w-3 rounded-full bg-orange-300" />
<span class="text-gray-600">正确数量</span>
</div>
</div>
</div>
<!-- 柱状图 -->
<div class="flex flex-1 items-end justify-around px-8 pb-2">
<div v-for="item in chartData" :key="item.group" class="w-16 flex flex-col items-center gap-2">
<div class="h-32 flex items-end gap-2">
<!-- Blue Bar -->
<div
class="relative w-6 rounded-t-md from-blue-500 to-blue-300 bg-gradient-to-t transition-all duration-1000"
:style="{ height: `${item.total * 2}px` }"
>
<span class="absolute left-1/2 text-blue-800 font-bold -top-6 -translate-x-1/2">{{ item.total }}</span>
</div>
<!-- Orange Bar -->
<div
class="relative w-6 rounded-t-md from-orange-400 to-orange-200 bg-gradient-to-t transition-all duration-1000"
:style="{ height: `${item.correct * 2}px` }"
>
<span class="absolute left-1/2 text-orange-600 font-bold -top-6 -translate-x-1/2">{{ item.correct
}}</span>
</div>
</div>
<div class="mt-2 text-blue-800 font-bold">
{{ item.group }}
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Question Renderer Override if needed */
:deep(.question-content) {
font-size: 4rem;
color: #ef4444;
/* red-500 */
font-weight: bold;
}
</style>

View File

@ -0,0 +1,63 @@
<script setup lang="ts">
import type { ActivityNode } from '../types'
defineProps<{ node: ActivityNode }>()
defineEmits(['back', 'draw'])
</script>
<template>
<div class="h-full w-full flex flex-col items-center pt-44">
<!-- 标题卷轴 -->
<div class="relative mb-16">
<div class="relative z-10 h-20 min-w-[300px] flex items-center justify-center border-2 border-orange-200 rounded-lg from-orange-100 to-orange-50 bg-gradient-to-b px-12 shadow-lg">
<div class="absolute top-1/2 h-24 w-6 border-r-4 border-yellow-600 rounded-l-md bg-teal-700 shadow-md -left-3 -translate-y-1/2">
<div class="absolute left-1 top-2 h-2 w-full rounded-full bg-teal-600/50" />
<div class="absolute bottom-2 left-1 h-2 w-full rounded-full bg-teal-600/50" />
</div>
<div class="absolute top-1/2 h-24 w-6 border-l-4 border-yellow-600 rounded-r-md bg-teal-700 shadow-md -right-3 -translate-y-1/2">
<div class="absolute right-1 top-2 h-2 w-full rounded-full bg-teal-600/50" />
<div class="absolute bottom-2 right-1 h-2 w-full rounded-full bg-teal-600/50" />
</div>
<h1 class="text-3xl text-gray-800 font-bold tracking-widest font-serif">
{{ node.moduleName }}
</h1>
</div>
</div>
<!-- 规则描述 -->
<div class="mb-20 max-w-4xl text-center">
<div class="text-2xl text-gray-800 font-medium leading-relaxed tracking-wide">
{{ node.description }}
</div>
</div>
<!-- 抽题按钮 (大圆形) -->
<div class="group relative">
<button
class="h-64 w-64 flex flex-col items-center justify-center border-4 border-blue-400 rounded-full bg-gray-200/80 text-3xl text-gray-700 font-bold shadow-xl transition-all active:scale-95 hover:scale-105"
@click="$emit('draw')"
>
<span>抽题</span>
</button>
</div>
<!-- 底部按钮 -->
<div class="fixed bottom-12 left-12">
<button
class="border-2 border-red-400 rounded-full bg-white px-10 py-2 text-xl text-red-500 font-bold shadow-md transition hover:bg-red-50"
@click="$emit('back')"
>
返回
</button>
</div>
<div class="fixed bottom-12 right-12">
<button
class="rounded-full from-red-400 to-red-500 bg-gradient-to-r px-10 py-2 text-xl text-white font-bold shadow-lg transition hover:from-red-500 hover:to-red-600"
@click="$emit('draw')"
>
开始答题
</button>
</div>
</div>
</template>

View File

@ -0,0 +1,120 @@
<script setup lang="ts">
import type { QuestionContent, UiTemplateType } from '../types'
import { computed } from 'vue'
const props = defineProps<{
template: UiTemplateType
content: QuestionContent
}>()
const pinyinChars = computed(() => {
if (props.template === 'TEMPLATE_WORD_DICTATION' && props.content.pinyin) {
return props.content.pinyin.split(' ')
}
return []
})
</script>
<template>
<div class="min-h-[400px] w-full flex items-center justify-center p-10">
<!-- 模板A: 汉字听写-提示 (拼音+提示) -->
<div v-if="template === 'TEMPLATE_DICTATION_HINT'" class="text-center">
<div class="mb-6 inline-block text-[120px] text-red-500 font-bold leading-none" style="text-shadow: 2px 2px 4px rgba(0,0,0,0.1);">
{{ content.pinyin }}
</div>
<!-- <div class="text-4xl text-gray-800 font-bold">
{{ content.hint }}
</div> -->
</div>
<!-- 模板B: 汉字听写-同音字 (大字) -->
<div v-else-if="template === 'TEMPLATE_DICTATION_HOMOPHONE'" class="text-center">
<div class="text-[100px] text-red-600 font-bold">
{{ content.mainText }}
</div>
</div>
<!-- 模板C: 汉字加一加 (提示+部件) -->
<div v-else-if="template === 'TEMPLATE_COMPONENT_ADD'" class="text-center">
<div class="text-[100px] text-red-600 font-bold">
{{ content.component }}
</div>
</div>
<!-- 模板D: 词语听写 (拼音+提示) -->
<div v-else-if="template === 'TEMPLATE_WORD_DICTATION'" class="flex flex-col items-center">
<div class="mb-8 flex flex-wrap justify-center gap-6">
<div
v-for="(char, index) in pinyinChars"
:key="index"
class="relative h-32 w-32 flex select-none items-center justify-center border-2 border-red-500 bg-white"
>
<!-- 米字格背景 -->
<div class="pointer-events-none absolute inset-0 z-0 h-full w-full">
<!-- 横虚线 -->
<div class="absolute left-0 top-1/2 h-[1px] w-full border-t border-red-400 border-dashed opacity-60" />
<!-- 竖虚线 -->
<div class="absolute left-1/2 top-0 h-full w-[1px] border-l border-red-400 border-dashed opacity-60" />
<!-- 斜虚线 (SVG) -->
<svg width="100%" height="100%" class="absolute inset-0 opacity-60">
<line x1="0" y1="0" x2="100%" y2="100%" stroke="#f87171" stroke-width="1" stroke-dasharray="4 2" />
<line x1="100%" y1="0" x2="0" y2="100%" stroke="#f87171" stroke-width="1" stroke-dasharray="4 2" />
</svg>
</div>
<!-- 文字 -->
<span class="z-10 text-5xl text-gray-800 font-bold font-sans">{{ char }}</span>
</div>
</div>
</div>
<!-- 模板E: 成语-文字要求 -->
<div v-else-if="template === 'TEMPLATE_IDIOM_TEXT_REQ'" class="text-center">
<div class="mb-8 text-4xl text-gray-800 font-bold">
{{ content.requirement }}
</div>
<!-- <div class="rounded bg-gray-100 px-4 py-2 text-xl text-gray-500">
示例{{ content.example }}
</div> -->
</div>
<!-- 模板F: 成语-看图 (使用Div占位) -->
<div v-else-if="template === 'TEMPLATE_IDIOM_IMAGE'" class="flex flex-col items-center text-center">
<div class="mb-6 h-60 w-80 flex items-center justify-center">
<!-- <span class="text-gray-400">图片展示区域<br></span> -->
<img :src="content.image" alt="idiom image" class="h-full w-full object-contain">
</div>
<!-- <div class="text-xl text-gray-600 font-bold">
提示{{ content.hint }}
</div> -->
</div>
<!-- 模板G: 诗词理解-选择题 -->
<div v-else-if="template === 'TEMPLATE_POETRY_MULTIPLE_CHOICE'" class="w-full flex flex-col items-center">
<div class="mb-8 text-3xl text-gray-800 font-bold leading-relaxed">
{{ content.questionText }}
</div>
<div class="grid grid-cols-1 w-full gap-6 lg:grid-cols-3 md:grid-cols-2">
<div
v-for="(option, index) in content.options"
:key="index"
class="group flex flex-col cursor-pointer items-center border-2 border-transparent rounded-xl bg-gray-50 p-4 transition-all hover:border-red-400 hover:bg-red-50 hover:shadow-lg"
>
<div v-if="option.image" class="mb-3 h-40 w-full flex items-center justify-center overflow-hidden rounded-lg bg-white p-2">
<img :src="option.image" :alt="option.label" class="h-full w-full object-contain transition-transform group-hover:scale-105">
</div>
<div class="flex items-center gap-3">
<div class="h-8 w-8 flex items-center justify-center rounded-full bg-red-100 text-red-600 font-bold group-hover:bg-red-500 group-hover:text-white">
{{ option.label }}
</div>
<span class="text-xl text-gray-700 font-medium group-hover:text-red-700">{{ option.text }}</span>
</div>
</div>
</div>
</div>
<div v-else class="text-red-500">
未知的题目模板: {{ template }}
</div>
</div>
</template>

View File

@ -0,0 +1,301 @@
<script setup lang="ts">
import type { QuestionContent, UiTemplateType } from '../types'
import { computed } from 'vue'
const props = defineProps<{
template: UiTemplateType
content: QuestionContent
}>()
const pinyinChars = computed(() => {
if (props.template === 'TEMPLATE_WORD_DICTATION' && props.content.title) {
return props.content.title.split(' ')
}
return []
})
</script>
<template>
<div class="min-h-[400px] w-full flex items-center justify-center p-10">
<!-- 模板A: 汉字听写-提示 (拼音+提示) -->
<div v-if="template === 'TEMPLATE_DICTATION_HINT'" class="text-center">
<div class="mb-6 inline-block text-[120px] text-red-500 font-bold leading-none" style="text-shadow: 2px 2px 4px rgba(0,0,0,0.1);">
{{ content.title }}
</div>
<!-- <div class="text-4xl text-gray-800 font-bold">
{{ content.meta?.hint }}
</div> -->
</div>
<!-- 模板B: 汉字听写-同音字 (大字) -->
<div v-else-if="template === 'TEMPLATE_DICTATION_HOMOPHONE'" class="text-center">
<div class="text-[100px] text-red-600 font-bold">
{{ content.title }}
</div>
</div>
<!-- 模板C: 汉字加一加 (提示+部件) -->
<div v-else-if="template === 'TEMPLATE_COMPONENT_ADD'" class="text-center">
<div class="text-[100px] text-red-600 font-bold">
{{ content.title }}
</div>
</div>
<!-- 模板D: 词语听写 (拼音+提示) -->
<div v-else-if="template === 'TEMPLATE_WORD_DICTATION'" class="flex flex-col items-center">
<div class="mb-8 flex flex-wrap justify-center gap-6">
<div
v-for="(char, index) in pinyinChars"
:key="index"
class="relative h-32 w-32 flex select-none items-center justify-center border-2 border-red-500 bg-white"
>
<!-- 米字格背景 -->
<div class="pointer-events-none absolute inset-0 z-0 h-full w-full">
<!-- 横虚线 -->
<div class="absolute left-0 top-1/2 h-[1px] w-full border-t border-red-400 border-dashed opacity-60" />
<!-- 竖虚线 -->
<div class="absolute left-1/2 top-0 h-full w-[1px] border-l border-red-400 border-dashed opacity-60" />
<!-- 斜虚线 (SVG) -->
<svg width="100%" height="100%" class="absolute inset-0 opacity-60">
<line x1="0" y1="0" x2="100%" y2="100%" stroke="#f87171" stroke-width="1" stroke-dasharray="4 2" />
<line x1="100%" y1="0" x2="0" y2="100%" stroke="#f87171" stroke-width="1" stroke-dasharray="4 2" />
</svg>
</div>
<!-- 文字 -->
<span class="z-10 text-5xl text-gray-800 font-bold font-sans">{{ char }}</span>
</div>
</div>
</div>
<!-- 模板E: 成语-文字要求 -->
<div v-else-if="template === 'TEMPLATE_IDIOM_TEXT_REQ'" class="text-center">
<div class="mb-8 text-4xl text-gray-800 font-bold">
{{ content.title }}
</div>
<!-- <div class="rounded bg-gray-100 px-4 py-2 text-xl text-gray-500">
示例{{ content.meta?.example }}
</div> -->
</div>
<!-- 模板F: 成语-看图 (使用Div占位) -->
<div v-else-if="template === 'TEMPLATE_IDIOM_IMAGE'" class="flex flex-col items-center text-center">
<div class="mb-6 h-60 w-80 flex items-center justify-center">
<!-- <span class="text-gray-400">图片展示区域<br></span> -->
<img :src="content.titleImage" alt="idiom image" class="h-full w-full object-contain">
</div>
<!-- <div class="text-xl text-gray-600 font-bold">
提示{{ content.meta?.hint }}
</div> -->
</div>
<!-- 模板G: 诗词理解-选择题 (通用选择题模板) -->
<div v-else-if="template === 'TEMPLATE_POETRY_MULTIPLE_CHOICE'" class="w-full flex flex-col items-center">
<div class="mb-8 text-3xl text-gray-800 font-bold leading-relaxed">
{{ content.title }}
</div>
<div class="options-container">
<div
v-for="(option, index) in content.options"
:key="index"
class="option-card group"
:class="{
'is-mixed': !option.renderType || option.renderType === 'mixed' || option.renderType === 'image',
'is-text': option.renderType === 'text',
}"
>
<!-- 1. 图片渲染模式 -->
<div v-if="option.image" class="option-image-wrapper">
<img :src="option.image" :alt="option.label" class="option-image">
</div>
<!-- 2. 田字格渲染模式 -->
<div v-if="option.renderType === 'tianzige'" class="tianzige-wrapper">
<!-- 米字格背景 -->
<div class="tianzige-bg">
<div class="line-horizontal" />
<div class="line-vertical" />
<svg width="100%" height="100%" class="line-diagonal">
<line x1="0" y1="0" x2="100%" y2="100%" stroke="#f87171" stroke-width="1" stroke-dasharray="4 2" />
<line x1="100%" y1="0" x2="0" y2="100%" stroke="#f87171" stroke-width="1" stroke-dasharray="4 2" />
</svg>
</div>
<span class="tianzige-text">{{ option.text }}</span>
</div>
<!-- 选项标签和文字 -->
<div class="option-content">
<div class="option-label">
{{ option.label }}
</div>
<!-- 如果不是田字格模式显示普通文本 -->
<span v-if="option.renderType !== 'tianzige'" class="option-text">
{{ option.text }}
</span>
</div>
</div>
</div>
</div>
<div v-else class="text-red-500">
未知的题目模板: {{ template }}
</div>
</div>
</template>
<style lang="scss" scoped>
.options-container {
width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2rem;
margin-bottom: 3rem;
.option-card {
width: 320px;
height: 260px;
}
}
.option-card {
display: flex;
cursor: pointer;
align-items: center;
justify-content: center;
border: 2px solid transparent;
border-radius: 0.75rem; /* rounded-xl */
background-color: #f9fafb; /* bg-gray-50 */
padding: 1rem; /* p-4 */
transition: all 0.3s;
&:hover {
border-color: #f87171; /* border-red-400 */
background-color: #fef2f2; /* bg-red-50 */
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05); /* shadow-lg */
}
&.is-mixed {
flex-direction: column;
}
&.is-text {
flex-direction: row;
gap: 1rem; /* gap-4 */
}
}
.option-image-wrapper {
margin-bottom: 1rem;
height: 9rem;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 0.5rem; /* rounded-lg */
padding: 0.5rem; /* p-2 */
}
.option-image {
height: 100%;
width: 100%;
object-fit: contain;
transition: transform 0.3s;
.group:hover & {
transform: scale(1.05);
}
}
.tianzige-wrapper {
position: relative;
margin-bottom: 0.75rem; /* mb-3 */
height: 8rem; /* h-32 */
width: 8rem; /* w-32 */
display: flex;
user-select: none;
align-items: center;
justify-content: center;
border: 2px solid #ef4444; /* border-red-500 */
background-color: white;
}
.tianzige-bg {
pointer-events: none;
position: absolute;
inset: 0;
z-index: 0;
height: 100%;
width: 100%;
.line-horizontal {
position: absolute;
left: 0;
top: 50%;
height: 1px;
width: 100%;
border-top: 1px dashed #f87171; /* border-red-400 */
opacity: 0.6;
}
.line-vertical {
position: absolute;
left: 50%;
top: 0;
height: 100%;
width: 1px;
border-left: 1px dashed #f87171; /* border-red-400 */
opacity: 0.6;
}
.line-diagonal {
position: absolute;
inset: 0;
opacity: 0.6;
}
}
.tianzige-text {
font-family: 'KaiTi', 'STKaiti', serif; /* font-kaaiti */
z-index: 10;
font-size: 3.75rem; /* text-6xl */
font-weight: 700;
color: #1f2937; /* text-gray-800 */
}
.option-content {
display: flex;
align-items: center;
gap: 0.75rem; /* gap-3 */
}
.option-label {
height: 2rem; /* h-8 */
width: 2rem; /* w-8 */
display: flex;
align-items: center;
justify-content: center;
border-radius: 9999px; /* rounded-full */
background-color: #fee2e2; /* bg-red-100 */
color: #dc2626; /* text-red-600 */
font-weight: 700;
.group:hover & {
background-color: #ef4444; /* bg-red-500 */
color: white;
}
}
.option-text {
font-size: 1.25rem; /* text-xl */
color: #374151; /* text-gray-700 */
font-weight: 500;
.group:hover & {
color: #b91c1c; /* text-red-700 */
}
}
</style>

View File

@ -0,0 +1,41 @@
### 一、 文字版流程说明
1. 封面 (COVER)
- 动作 :点击“开始比赛”按钮。
- 跳转 :进入第一轮的【抽题/介绍页面】。
2. 抽题/介绍页面 (INTRO)
- 展示 :当前环节名称(如“汉字听一听”)、规则说明、大圆形的“抽题”按钮。
- 动作 :点击“抽题”或“开始答题”按钮。
- 跳转 :进入当前题目的【答题页面】,同时开始倒计时。
3. 答题页面 (GAME)
- 展示 :题目内容(如拼音、图片)、倒计时、实时答题进度图表。
- 触发条件 A :倒计时归零。
- 触发条件 B :点击隐藏的“下一题”区域(手动结束)。
- 跳转 :停止计时,进入当前题目的【答题图解页面】。
4. 答题图解页面 (ANSWER_ANALYSIS)
- 展示 4个战队的答题原笔迹支持点击放大、AI评分结果、人工改分输入框。
- 动作 :点击右上角“下一题”按钮。
- 逻辑判断
- 情况 A本轮还有题 :索引指向下一题 -> 回到【抽题/介绍页面】 (准备抽取下一题)。
- 情况 B本轮已结束还有下一轮 :索引指向下一轮第一题 -> 回到【抽题/介绍页面】 (展示新环节介绍)。
- 情况 C全场结束 :提示“全场比赛结束” -> 回到【封面】 。
阅读之星
feat(user): 新增用户端答题流程页面与相关功能
- 新增用户端答题流程主页面,包含封面、介绍、答题、答题图解四个状态
- 新增用户端组件:封面、介绍页、答题页、答题图解页、题目渲染器
- 新增用户端类型定义、模拟数据及业务常量
- 新增用户路由及类型声明
- 优化题库分类树,改为扁平化列表结构
- 完善比赛创建功能增加轮次类型、题目分数类型及关联AP字段
- 修复模板删除函数调用参数错误

View File

@ -0,0 +1,64 @@
export type UiTemplateType =
| 'TEMPLATE_DICTATION_HINT' // 汉字听写-提示 (jiu)
| 'TEMPLATE_DICTATION_HOMOPHONE' // 汉字听写-同音 (tang)
| 'TEMPLATE_COMPONENT_ADD' // 汉字加一加 (车)
| 'TEMPLATE_WORD_DICTATION' // 词语听写 (zhi re)
| 'TEMPLATE_IDIOM_TEXT_REQ' // 成语-文字要求 (反义字)
| 'TEMPLATE_IDIOM_IMAGE' // 成语-看图
| 'TEMPLATE_POETRY_MULTIPLE_CHOICE' // 诗词理解-选择题
export interface QuestionOption {
label: string // A, B, C
text: string // 选项文本/值
image?: string // 选项图片
renderType?: 'text' | 'image' | 'tianzige' | 'mixed' // 关键字段:控制选项渲染样式
}
export interface QuestionContent {
// 核心标准字段 (对应数据库列)
title: string // 题干/主要内容 (原 word, questionText, mainText, pinyin, requirement, component)
titleImage?: string // 题干图片 (原 image)
titleAudio?: string // 题干音频 (原 soundUrl)
options?: QuestionOption[] // 选项列表
// 扩展元数据 (对应数据库 JSON 字段)
meta?: {
hint?: string // 提示
example?: string // 示例
pinyin?: string // 如果 title 是汉字,这里存拼音;反之亦然
definition?: string // 释义
strokeCount?: number // 笔画数
radicals?: string // 部首
[key: string]: any
}
}
export interface Question {
id: string
content: QuestionContent
answer: string[]
}
export interface NodeConfig {
timeLimit: number
questionCount: number
scorePerQuestion: number
}
export interface ActivityNode {
nodeId: string
roundTitle: string
moduleName: string
batchName: string
description: string
uiTemplate: UiTemplateType
config: NodeConfig
questions: Question[]
}
export interface CompetitionData {
activityInfo: {
name: string
}
nodes: ActivityNode[]
}

View File

@ -0,0 +1,178 @@
import img from '@/assets/imgs/q5-img.png'
/**
* 模拟数据
* 真实后端数据可能是:
* 活动详情接口传活动id 获取 流程nodes
* 每次可能只返回一个n1前端先展示n1用户点击“抽题”后再请求n2。其中questions 可能不是在n1就返回而是点击抽提时候 传nodeId 去随机获取后前端保存
* 1. 包含 3 个节点N1, N2, N3
* 2. 每个节点包含 2 个批次(汉字听一听, 汉字加一加)
* 3. 每个批次包含 2 个问题Q1, Q2, Q3, Q4, Q5
*/
export const mockData = {
activityInfo: {
name: '星·辞海遨游',
},
nodes: [
{
nodeId: 'N1',
roundTitle: '第一轮',
moduleName: '汉字听一听',
batchName: '根据提示写汉字',
description: '请根据汉语拼音提示书写正确的汉字时间为15秒。',
uiTemplate: 'TEMPLATE_DICTATION_HINT',
config: { timeLimit: 5, questionCount: 2, scorePerQuestion: 1 },
questions: [
{
id: 'Q1',
content: { pinyin: 'jiū,表示小鸟的叫声', hint: '表示小鸟的叫声' },
answer: ['啾'],
},
{
id: 'Q2',
content: { pinyin: 'yāo', hint: '形容草木茂盛美丽' },
answer: ['夭'],
},
],
},
{
nodeId: 'N2',
roundTitle: '第二轮',
moduleName: '汉字听一听',
batchName: '同音字',
description: '诗词理解',
uiTemplate: 'TEMPLATE_DICTATION_HOMOPHONE',
config: { timeLimit: 5, questionCount: 2, scorePerQuestion: 1 },
questions: [
{
id: 'Q3',
content: { mainText: 'táng' },
answer: ['唐', '塘', '糖', '搪', '溏', '瑭', '鄌', '螗', '糖', '堂', '膛', '螳', '鄳', '樘', '镗', '棠', '饧'],
},
{
id: 'Q4',
content: { mainText: 'jù' },
answer: ['剧', '据', '巨', '拒', '聚', '炬', '距', '惧', '具', '俱', '沮', '咀', '矩', '锯'],
},
],
},
{
nodeId: 'N3',
roundTitle: '第二轮',
moduleName: '诗词理解',
batchName: '选择题',
description: '请选择正确的选项时间为30秒。',
uiTemplate: 'TEMPLATE_POETRY_MULTIPLE_CHOICE',
config: { timeLimit: 30, questionCount: 2, scorePerQuestion: 2 },
questions: [
{
id: 'Q3',
content: {
questionText: '《闻王昌龄左迁龙标遥有此寄》中,“左迁”的意思是()',
options: [
{ label: 'A', text: '贬官,降职', image: img },
{ label: 'B', text: '搬家', image: img },
{ label: 'C', text: '一路向西游玩', image: img },
],
},
answer: ['A'],
},
{
id: 'Q4',
content: {
questionText: '《静夜思》中,“举头望明月”的下一句是()',
options: [
{ label: 'A', text: '低头思故乡', image: img },
{ label: 'B', text: '疑是地上霜', image: img },
],
},
answer: ['A'],
},
],
},
{
nodeId: 'N4',
roundTitle: '第三轮',
moduleName: '汉字加一加',
batchName: '部件组合',
description: '请写出含有指定部件的汉字时间为60秒。',
uiTemplate: 'TEMPLATE_COMPONENT_ADD',
config: { timeLimit: 5, questionCount: 1, scorePerQuestion: 1 },
questions: [
{
id: 'Q5',
content: { component: '车' },
answer: ['辆', '轿', '转', '软', '轮', '辐', '输', '轨', '轩', '轻', '轶', '辄', '轴', '轧', '轼', '辊', '辕', '辙', '辍', '辗', '轸', '轭', '辚', '辖', '较', '辘', '轫', '轵', '辋', '轲', '轺', '臻', '软', '辌', '轷', '轻', '辁', '辒', '蚺', '韫', '轱', '辂', '轻', '轹', '辅', '舻', '辐', '斩', '连', '阵', '军', '轰', '库', '辈', '载', '辈', '晕', '辔'],
},
],
},
{
nodeId: 'N5',
roundTitle: '第四轮',
moduleName: '词语听写',
batchName: '拼音写词',
description: '请根据汉语拼音提示书写正确的词语时间为30秒。',
uiTemplate: 'TEMPLATE_WORD_DICTATION',
config: { timeLimit: 5, questionCount: 2, scorePerQuestion: 2 },
questions: [
{
id: 'Q6',
content: { pinyin: 'zhì rè', hint: '形容温度极高' },
answer: ['炙热'],
},
{
id: 'Q7',
content: { pinyin: 'hào hàn', hint: '形容广大繁多' },
answer: ['浩瀚'],
},
],
},
{
nodeId: 'N6',
roundTitle: '第五轮',
moduleName: '成语写一写',
batchName: '文字要求',
description: '请根据要求书写正确的成语时间为60秒。',
uiTemplate: 'TEMPLATE_IDIOM_TEXT_REQ',
config: { timeLimit: 5, questionCount: 1, scorePerQuestion: 1 },
questions: [
{
id: 'Q8',
content: { requirement: '请写出含有反义字的四字成语。' },
answer: ['七上八下', '前因后果', '大同小异', '东张西望', '左顾右盼', '南辕北辙', '深入浅出', '出生入死', '死去活来', '天翻地覆', '黑白分明'],
},
],
},
{
nodeId: 'N7',
roundTitle: '第六轮',
moduleName: '成语写一写',
batchName: '看图写成语',
description: '请根据图片书写正确的成语时间为30秒。',
uiTemplate: 'TEMPLATE_IDIOM_IMAGE',
config: { timeLimit: 30, questionCount: 2, scorePerQuestion: 2 },
questions: [
{
id: 'Q9',
content: { hint: '比喻事情已定', image: img },
answer: ['板上钉钉'],
},
{
id: 'Q10',
content: { hint: '比喻事情已定', image: img },
answer: ['板上钉钉'],
},
],
},
],
}
export const uiTemplatesMapping = {
TEMPLATE_DICTATION_HINT: '汉字听写-提示',
TEMPLATE_DICTATION_HOMOPHONE: '汉字听写-同音',
TEMPLATE_COMPONENT_ADD: '汉字加一加',
TEMPLATE_WORD_DICTATION: '词语听写',
TEMPLATE_IDIOM_TEXT_REQ: '成语-文字要求',
TEMPLATE_IDIOM_IMAGE: '成语-看图',
TEMPLATE_POETRY_MULTIPLE_CHOICE: '诗词理解-选择题',
}

View File

@ -15,6 +15,40 @@ export const mockData: CompetitionData = {
name: '星·辞海遨游',
},
nodes: [
{
nodeId: 'N3',
roundTitle: '第二轮',
moduleName: '诗词理解',
batchName: '选择题',
description: '请选择正确的选项时间为30秒。',
uiTemplate: 'TEMPLATE_POETRY_MULTIPLE_CHOICE',
config: { timeLimit: 300, questionCount: 2, scorePerQuestion: 2 },
questions: [
{
id: 'Q3',
content: {
title: '《闻王昌龄左迁龙标遥有此寄》中,“左迁”的意思是()',
options: [
{ label: 'A', text: '贬官,降职', image: img },
{ label: 'B', text: '搬家', image: img },
{ label: 'C', text: '一路向西游玩', image: img },
],
},
answer: ['A'],
},
{
id: 'Q4',
content: {
title: '《静夜思》中,“举头望明月”的下一句是()',
options: [
{ label: 'A', text: '低头思故乡', image: img },
{ label: 'B', text: '疑是地上霜', image: img },
],
},
answer: ['A'],
},
],
},
{
nodeId: 'N1',
roundTitle: '第一轮',
@ -26,12 +60,18 @@ export const mockData: CompetitionData = {
questions: [
{
id: 'Q1',
content: { pinyin: 'jiū,表示小鸟的叫声', hint: '表示小鸟的叫声' },
content: {
title: 'jiū,表示小鸟的叫声',
meta: { hint: '表示小鸟的叫声' },
},
answer: ['啾'],
},
{
id: 'Q2',
content: { pinyin: 'yāo', hint: '形容草木茂盛美丽' },
content: {
title: 'yāo',
meta: { hint: '形容草木茂盛美丽' },
},
answer: ['夭'],
},
],
@ -41,24 +81,25 @@ export const mockData: CompetitionData = {
roundTitle: '第二轮',
moduleName: '汉字听一听',
batchName: '同音字',
description: '请根据拼音书写同音字时间为30秒。',
description: '诗词理解',
uiTemplate: 'TEMPLATE_DICTATION_HOMOPHONE',
config: { timeLimit: 5, questionCount: 2, scorePerQuestion: 1 },
questions: [
{
id: 'Q3',
content: { mainText: 'táng' },
content: { title: 'táng' },
answer: ['唐', '塘', '糖', '搪', '溏', '瑭', '鄌', '螗', '糖', '堂', '膛', '螳', '鄳', '樘', '镗', '棠', '饧'],
},
{
id: 'Q4',
content: { mainText: 'jù' },
content: { title: 'jù' },
answer: ['剧', '据', '巨', '拒', '聚', '炬', '距', '惧', '具', '俱', '沮', '咀', '矩', '锯'],
},
],
},
{
nodeId: 'N3',
nodeId: 'N4',
roundTitle: '第三轮',
moduleName: '汉字加一加',
batchName: '部件组合',
@ -68,13 +109,13 @@ export const mockData: CompetitionData = {
questions: [
{
id: 'Q5',
content: { component: '车' },
content: { title: '车' },
answer: ['辆', '轿', '转', '软', '轮', '辐', '输', '轨', '轩', '轻', '轶', '辄', '轴', '轧', '轼', '辊', '辕', '辙', '辍', '辗', '轸', '轭', '辚', '辖', '较', '辘', '轫', '轵', '辋', '轲', '轺', '臻', '软', '辌', '轷', '轻', '辁', '辒', '蚺', '韫', '轱', '辂', '轻', '轹', '辅', '舻', '辐', '斩', '连', '阵', '军', '轰', '库', '辈', '载', '辈', '晕', '辔'],
},
],
},
{
nodeId: 'N4',
nodeId: 'N5',
roundTitle: '第四轮',
moduleName: '词语听写',
batchName: '拼音写词',
@ -84,18 +125,24 @@ export const mockData: CompetitionData = {
questions: [
{
id: 'Q6',
content: { pinyin: 'zhì rè', hint: '形容温度极高' },
content: {
title: 'zhì rè',
meta: { hint: '形容温度极高' },
},
answer: ['炙热'],
},
{
id: 'Q7',
content: { pinyin: 'hào hàn', hint: '形容广大繁多' },
content: {
title: 'hào hàn',
meta: { hint: '形容广大繁多' },
},
answer: ['浩瀚'],
},
],
},
{
nodeId: 'N5',
nodeId: 'N6',
roundTitle: '第五轮',
moduleName: '成语写一写',
batchName: '文字要求',
@ -105,13 +152,13 @@ export const mockData: CompetitionData = {
questions: [
{
id: 'Q8',
content: { requirement: '请写出含有反义字的四字成语。' },
content: { title: '请写出含有反义字的四字成语。' },
answer: ['七上八下', '前因后果', '大同小异', '东张西望', '左顾右盼', '南辕北辙', '深入浅出', '出生入死', '死去活来', '天翻地覆', '黑白分明'],
},
],
},
{
nodeId: 'N6',
nodeId: 'N7',
roundTitle: '第六轮',
moduleName: '成语写一写',
batchName: '看图写成语',
@ -121,16 +168,47 @@ export const mockData: CompetitionData = {
questions: [
{
id: 'Q9',
content: { hint: '比喻事情已定', image: img },
content: {
title: '',
titleImage: img,
meta: { hint: '比喻事情已定' },
},
answer: ['板上钉钉'],
},
{
id: 'Q10',
content: { hint: '比喻事情已定', image: img },
content: {
title: '',
titleImage: img,
meta: { hint: '比喻事情已定' },
},
answer: ['板上钉钉'],
},
],
},
{
nodeId: 'N8',
roundTitle: '第七轮',
moduleName: '汉字辨析',
batchName: '选字填空',
description: '请选择正确的汉字填入括号中。',
uiTemplate: 'TEMPLATE_POETRY_MULTIPLE_CHOICE',
config: { timeLimit: 20, questionCount: 1, scorePerQuestion: 2 },
questions: [
{
id: 'Q11',
content: {
title: '下列哪个字是“没”的繁体字?',
options: [
{ label: 'A', text: '没', renderType: 'tianzige' },
{ label: 'B', text: '沒', renderType: 'tianzige' },
{ label: 'C', text: '殁', renderType: 'tianzige' },
],
},
answer: ['B'],
},
],
},
],
}
@ -141,4 +219,5 @@ export const uiTemplatesMapping = {
TEMPLATE_WORD_DICTATION: '词语听写',
TEMPLATE_IDIOM_TEXT_REQ: '成语-文字要求',
TEMPLATE_IDIOM_IMAGE: '成语-看图',
TEMPLATE_POETRY_MULTIPLE_CHOICE: '诗词理解-选择题',
}

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue'
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
import { mockData } from './data/mock' // 引入模拟数据
import AnswerAnalysisComp from './modules/AnswerAnalysisComp.vue'
import CoverComp from './modules/CoverComp.vue' // 封面组件
@ -104,65 +105,48 @@ onUnmounted(() => {
</script>
<template>
<div class="user-app-container relative h-screen w-full overflow-hidden font-sans">
<!-- 背景图占位 (实际项目中应为 img background-image) -->
<div class="absolute inset-0 z-0 bg-blue-100/50">
<!-- 模拟云纹/山水背景 -->
<div class="absolute bottom-0 h-1/3 w-full from-teal-200/50 to-transparent bg-gradient-to-t" />
<div class="absolute top-0 h-1/4 w-full from-blue-200/50 to-transparent bg-gradient-to-b" />
<CompetitionLayout>
<div class="h-screen w-full overflow-hidden font-sans">
<!-- 主内容区域 -->
<div class="relative z-10 h-full w-full">
<!-- 状态 1: 封面 -->
<CoverComp
v-if="status === 'COVER'"
:title="data.activityInfo.name"
:nodes="data.nodes"
@start="handleStart"
/>
<!-- 状态 2: 介绍页 -->
<IntroComp
v-else-if="status === 'INTRO'"
:node="currentNode"
@back="status = 'COVER'"
@draw="handleDraw"
/>
<!-- 状态 3: 答题页 -->
<GameComp
v-else-if="status === 'GAME'"
:node="currentNode"
:question="currentQuestion"
:sub-step="subStep"
:time-left="timeLeft"
@back="status = 'INTRO'"
@next="handleToAnalysis"
/>
<!-- 状态 4: 答题图解页 -->
<AnswerAnalysisComp
v-else-if="status === 'ANSWER_ANALYSIS'"
:question-id="currentQuestion.id"
@back="status = 'GAME'"
@next="handleNext"
/>
</div>
</div>
<!-- 顶部左右信息 (固定) -->
<div class="absolute left-6 top-4 z-20 text-lg text-gray-700 font-medium tracking-wide">
教育学会
</div>
<div class="absolute right-6 top-4 z-20 text-lg text-gray-700 font-medium tracking-wide">
树人研究院
</div>
<!-- 主内容区域 -->
<div class="relative z-10 h-full w-full">
<!-- 状态 1: 封面 -->
<CoverComp
v-if="status === 'COVER'"
:title="data.activityInfo.name"
:nodes="data.nodes"
@start="handleStart"
/>
<!-- 状态 2: 介绍页 -->
<IntroComp
v-else-if="status === 'INTRO'"
:node="currentNode"
@back="status = 'COVER'"
@draw="handleDraw"
/>
<!-- 状态 3: 答题页 -->
<GameComp
v-else-if="status === 'GAME'"
:node="currentNode"
:question="currentQuestion"
:sub-step="subStep"
:time-left="timeLeft"
@back="status = 'INTRO'"
@next="handleToAnalysis"
/>
<!-- 状态 4: 答题图解页 -->
<AnswerAnalysisComp
v-else-if="status === 'ANSWER_ANALYSIS'"
:question-id="currentQuestion.id"
@back="status = 'GAME'"
@next="handleNext"
/>
</div>
</div>
</CompetitionLayout>
</template>
<style scoped>
.user-app-container {
/* 基础背景色,防止图片加载失败时太突兀 */
background-color: #eef7ff;
}
</style>

View File

@ -8,8 +8,8 @@ const props = defineProps<{
}>()
const pinyinChars = computed(() => {
if (props.template === 'TEMPLATE_WORD_DICTATION' && props.content.pinyin) {
return props.content.pinyin.split(' ')
if (props.template === 'TEMPLATE_WORD_DICTATION' && props.content.title) {
return props.content.title.split(' ')
}
return []
})
@ -20,24 +20,24 @@ const pinyinChars = computed(() => {
<!-- 模板A: 汉字听写-提示 (拼音+提示) -->
<div v-if="template === 'TEMPLATE_DICTATION_HINT'" class="text-center">
<div class="mb-6 inline-block text-[120px] text-red-500 font-bold leading-none" style="text-shadow: 2px 2px 4px rgba(0,0,0,0.1);">
{{ content.pinyin }}
{{ content.title }}
</div>
<!-- <div class="text-4xl text-gray-800 font-bold">
{{ content.hint }}
{{ content.meta?.hint }}
</div> -->
</div>
<!-- 模板B: 汉字听写-同音字 (大字) -->
<div v-else-if="template === 'TEMPLATE_DICTATION_HOMOPHONE'" class="text-center">
<div class="text-[100px] text-red-600 font-bold">
{{ content.mainText }}
{{ content.title }}
</div>
</div>
<!-- 模板C: 汉字加一加 (提示+部件) -->
<div v-else-if="template === 'TEMPLATE_COMPONENT_ADD'" class="text-center">
<div class="text-[100px] text-red-600 font-bold">
{{ content.component }}
{{ content.title }}
</div>
</div>
@ -71,10 +71,10 @@ const pinyinChars = computed(() => {
<!-- 模板E: 成语-文字要求 -->
<div v-else-if="template === 'TEMPLATE_IDIOM_TEXT_REQ'" class="text-center">
<div class="mb-8 text-4xl text-gray-800 font-bold">
{{ content.requirement }}
{{ content.title }}
</div>
<!-- <div class="rounded bg-gray-100 px-4 py-2 text-xl text-gray-500">
示例{{ content.example }}
示例{{ content.meta?.example }}
</div> -->
</div>
@ -82,15 +82,220 @@ const pinyinChars = computed(() => {
<div v-else-if="template === 'TEMPLATE_IDIOM_IMAGE'" class="flex flex-col items-center text-center">
<div class="mb-6 h-60 w-80 flex items-center justify-center">
<!-- <span class="text-gray-400">图片展示区域<br></span> -->
<img :src="content.image" alt="idiom image" class="h-full w-full object-contain">
<img :src="content.titleImage" alt="idiom image" class="h-full w-full object-contain">
</div>
<!-- <div class="text-xl text-gray-600 font-bold">
提示{{ content.hint }}
提示{{ content.meta?.hint }}
</div> -->
</div>
<!-- 模板G: 诗词理解-选择题 (通用选择题模板) -->
<div v-else-if="template === 'TEMPLATE_POETRY_MULTIPLE_CHOICE'" class="w-full flex flex-col items-center">
<div class="mb-8 text-3xl text-gray-800 font-bold leading-relaxed">
{{ content.title }}
</div>
<div class="options-container">
<div
v-for="(option, index) in content.options"
:key="index"
class="option-card group"
:class="{
'is-mixed': !option.renderType || option.renderType === 'mixed' || option.renderType === 'image',
'is-text': option.renderType === 'text',
}"
>
<!-- 1. 图片渲染模式 -->
<div v-if="option.image" class="option-image-wrapper">
<img :src="option.image" :alt="option.label" class="option-image">
</div>
<!-- 2. 田字格渲染模式 -->
<div v-if="option.renderType === 'tianzige'" class="tianzige-wrapper">
<!-- 米字格背景 -->
<div class="tianzige-bg">
<div class="line-horizontal" />
<div class="line-vertical" />
<svg width="100%" height="100%" class="line-diagonal">
<line x1="0" y1="0" x2="100%" y2="100%" stroke="#f87171" stroke-width="1" stroke-dasharray="4 2" />
<line x1="100%" y1="0" x2="0" y2="100%" stroke="#f87171" stroke-width="1" stroke-dasharray="4 2" />
</svg>
</div>
<span class="tianzige-text">{{ option.text }}</span>
</div>
<!-- 选项标签和文字 -->
<div class="option-content">
<div class="option-label">
{{ option.label }}
</div>
<!-- 如果不是田字格模式显示普通文本 -->
<span v-if="option.renderType !== 'tianzige'" class="option-text">
{{ option.text }}
</span>
</div>
</div>
</div>
</div>
<div v-else class="text-red-500">
未知的题目模板: {{ template }}
</div>
</div>
</template>
<style lang="scss" scoped>
.options-container {
width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2rem;
margin-bottom: 3rem;
.option-card {
width: 320px;
height: 260px;
}
}
.option-card {
display: flex;
cursor: pointer;
align-items: center;
justify-content: center;
border: 2px solid transparent;
border-radius: 0.75rem; /* rounded-xl */
background-color: #f9fafb; /* bg-gray-50 */
padding: 1rem; /* p-4 */
transition: all 0.3s;
&:hover {
border-color: #f87171; /* border-red-400 */
background-color: #fef2f2; /* bg-red-50 */
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05); /* shadow-lg */
}
&.is-mixed {
flex-direction: column;
}
&.is-text {
flex-direction: row;
gap: 1rem; /* gap-4 */
}
}
.option-image-wrapper {
margin-bottom: 1rem;
height: 9rem;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 0.5rem; /* rounded-lg */
padding: 0.5rem; /* p-2 */
}
.option-image {
height: 100%;
width: 100%;
object-fit: contain;
transition: transform 0.3s;
.group:hover & {
transform: scale(1.05);
}
}
.tianzige-wrapper {
position: relative;
margin-bottom: 0.75rem; /* mb-3 */
height: 8rem; /* h-32 */
width: 8rem; /* w-32 */
display: flex;
user-select: none;
align-items: center;
justify-content: center;
border: 2px solid #ef4444; /* border-red-500 */
background-color: white;
}
.tianzige-bg {
pointer-events: none;
position: absolute;
inset: 0;
z-index: 0;
height: 100%;
width: 100%;
.line-horizontal {
position: absolute;
left: 0;
top: 50%;
height: 1px;
width: 100%;
border-top: 1px dashed #f87171; /* border-red-400 */
opacity: 0.6;
}
.line-vertical {
position: absolute;
left: 50%;
top: 0;
height: 100%;
width: 1px;
border-left: 1px dashed #f87171; /* border-red-400 */
opacity: 0.6;
}
.line-diagonal {
position: absolute;
inset: 0;
opacity: 0.6;
}
}
.tianzige-text {
font-family: 'KaiTi', 'STKaiti', serif; /* font-kaaiti */
z-index: 10;
font-size: 3.75rem; /* text-6xl */
font-weight: 700;
color: #1f2937; /* text-gray-800 */
}
.option-content {
display: flex;
align-items: center;
gap: 0.75rem; /* gap-3 */
}
.option-label {
height: 2rem; /* h-8 */
width: 2rem; /* w-8 */
display: flex;
align-items: center;
justify-content: center;
border-radius: 9999px; /* rounded-full */
background-color: #fee2e2; /* bg-red-100 */
color: #dc2626; /* text-red-600 */
font-weight: 700;
.group:hover & {
background-color: #ef4444; /* bg-red-500 */
color: white;
}
}
.option-text {
font-size: 1.25rem; /* text-xl */
color: #374151; /* text-gray-700 */
font-weight: 500;
.group:hover & {
color: #b91c1c; /* text-red-700 */
}
}
</style>

View File

@ -5,15 +5,32 @@ export type UiTemplateType =
| 'TEMPLATE_WORD_DICTATION' // 词语听写 (zhi re)
| 'TEMPLATE_IDIOM_TEXT_REQ' // 成语-文字要求 (反义字)
| 'TEMPLATE_IDIOM_IMAGE' // 成语-看图
| 'TEMPLATE_POETRY_MULTIPLE_CHOICE' // 诗词理解-选择题
export interface QuestionOption {
label: string // A, B, C
text: string // 选项文本/值
image?: string // 选项图片
renderType?: 'text' | 'image' | 'tianzige' | 'mixed' // 关键字段:控制选项渲染样式
}
export interface QuestionContent {
pinyin?: string
hint?: string
mainText?: string // 大字展示
component?: string // 部件
requirement?: string // 题目要求
example?: string // 例子
image?: string // 图片路径
// 核心标准字段 (对应数据库列)
title: string // 题干/主要内容 (原 word, questionText, mainText, pinyin, requirement, component)
titleImage?: string // 题干图片 (原 image)
titleAudio?: string // 题干音频 (原 soundUrl)
options?: QuestionOption[] // 选项列表
// 扩展元数据 (对应数据库 JSON 字段)
meta?: {
hint?: string // 提示
example?: string // 示例
pinyin?: string // 如果 title 是汉字,这里存拼音;反之亦然
definition?: string // 释义
strokeCount?: number // 笔画数
radicals?: string // 部首
[key: string]: any
}
}
export interface Question {