feat(user): 重构用户端竞赛流程并添加新页面
- 将原单页竞赛流程拆分为独立路由页面(首页、封面、规则、抽题、答题、图解、组别、队伍) - 新增 Pinia store 管理竞赛状态、计时器和音频控制 - 添加 SCSS 变量文件定义主题色 - 更新路由配置和类型定义以支持新页面结构 - 重构 QuestionRenderer 组件优化样式 - 添加图标依赖并更新国际化配置 - 移动图片资源到用户目录并清理旧文件
@ -52,6 +52,7 @@
|
||||
"pinyin-pro": "^3.28.0",
|
||||
"quill-toolbar-tip": "^0.1.0",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"v-scale-screen": "^2.3.0",
|
||||
"vue": "3.5.26",
|
||||
"vue-draggable-plus": "0.6.0",
|
||||
"vue-i18n": "11.2.7",
|
||||
|
||||
46
apps/admin/src/assets/imgs/user/cover-back.svg
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
69
apps/admin/src/assets/imgs/user/cover-bg-active.svg
Normal file
|
After Width: | Height: | Size: 7.5 MiB |
|
Before Width: | Height: | Size: 825 KiB After Width: | Height: | Size: 825 KiB |
|
Before Width: | Height: | Size: 11 MiB After Width: | Height: | Size: 11 MiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 5.2 MiB After Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 16 MiB After Width: | Height: | Size: 16 MiB |
|
Before Width: | Height: | Size: 12 MiB After Width: | Height: | Size: 12 MiB |
|
Before Width: | Height: | Size: 4.0 MiB After Width: | Height: | Size: 4.0 MiB |
@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import VScaleScreen from 'v-scale-screen'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import _defaultBg from '@/assets/imgs/home-bg.svg'
|
||||
import _defaultBg from '@/assets/imgs/user/home-bg.svg'
|
||||
|
||||
interface Props {
|
||||
/** 是否显示返回按钮 */
|
||||
@ -9,12 +10,33 @@ interface Props {
|
||||
showNext?: boolean
|
||||
/** 背景图片URL,如果不传则尝试内部获取或使用默认 */
|
||||
bgUrl?: string
|
||||
/** 下一步按钮的文本,如果存在则显示为文字按钮,否则显示为图标按钮 */
|
||||
nextBtnText?: string
|
||||
/** 页面标题 */
|
||||
title?: string
|
||||
/** 是否显示标题 */
|
||||
showTitle?: boolean
|
||||
/** 按钮操作栏位置:'bottom' | 'top',默认 'bottom' */
|
||||
actionPosition?: 'bottom' | 'top'
|
||||
/** 返回按钮的文本,如果存在则显示为文字按钮,否则显示为图标按钮 */
|
||||
backBtnText?: string
|
||||
/** 按钮主题:'primary' (红色填充) | 'light' (白色背景红字),默认 'primary' */
|
||||
btnTheme?: 'primary' | 'light'
|
||||
/** 是否禁用下一步按钮 */
|
||||
nextDisabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showBack: true,
|
||||
showNext: true,
|
||||
bgUrl: undefined,
|
||||
nextBtnText: undefined,
|
||||
title: undefined,
|
||||
showTitle: true,
|
||||
actionPosition: 'bottom',
|
||||
backBtnText: undefined,
|
||||
btnTheme: 'primary',
|
||||
nextDisabled: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@ -40,6 +62,8 @@ async function fetchSystemConfig() {
|
||||
innerBgUrl.value = _defaultBg
|
||||
}
|
||||
|
||||
const showContent = ref(false)
|
||||
|
||||
function handleBack() {
|
||||
emit('back')
|
||||
// 如果没有监听 back 事件,默认行为可以是路由返回
|
||||
@ -47,65 +71,137 @@ function handleBack() {
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (props.nextDisabled)
|
||||
return
|
||||
emit('next')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSystemConfig()
|
||||
setTimeout(() => {
|
||||
showContent.value = true
|
||||
}, 100)
|
||||
})
|
||||
</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="competition-layout-wrapper">
|
||||
<!-- Background Layer (Outside Scale to cover full screen) -->
|
||||
<div class="fixed-bg" :style="innerBgUrl ? { backgroundImage: `url(${innerBgUrl})` } : {}" />
|
||||
|
||||
<div class="glass-overlay">
|
||||
<!-- Main Content Slot -->
|
||||
<main class="main-content">
|
||||
<slot />
|
||||
</main>
|
||||
<VScaleScreen width="1920" height="1080">
|
||||
<div class="competition-layout">
|
||||
<!-- Header -->
|
||||
<header class="page-header text-[30px] text-[#333333] font-bold">
|
||||
<span class="org-name">教育学会</span>
|
||||
<span class="org-name">树人研究院</span>
|
||||
</header>
|
||||
|
||||
<!-- 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>
|
||||
<div class="glass-overlay">
|
||||
<!-- title -->
|
||||
<section v-if="showTitle" class="title-section">
|
||||
<Transition name="fade-slide-down" appear>
|
||||
<div v-if="showContent" class="title-section">
|
||||
<div class="scroll-bg">
|
||||
<h1 class="main-title">
|
||||
{{ title }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</section>
|
||||
|
||||
<button v-if="showNext" class="action-btn next-btn" @click="handleNext">
|
||||
<SvgIcon icon="mdi:arrow-right" class="arrow-icon" />
|
||||
</button>
|
||||
<!-- Main Content Slot -->
|
||||
<main class="main-content">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<!-- Footer Action -->
|
||||
<footer class="page-footer w-full flex items-center justify-between px-20" :class="[actionPosition, btnTheme]">
|
||||
<button v-if="showBack" class="action-btn back-btn" :class="{ 'text-btn': backBtnText }" @click="handleBack">
|
||||
<span v-if="backBtnText" class="btn-text">{{ backBtnText }}</span>
|
||||
<SvgIcon v-else icon="mdi:arrow-left" class="arrow-icon" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="showNext"
|
||||
class="action-btn next-btn text-btn"
|
||||
:class="{ 'is-disabled': nextDisabled }"
|
||||
@click="handleNext"
|
||||
>
|
||||
<span class="btn-text">{{ nextBtnText || '下一步' }}</span>
|
||||
<!-- <SvgIcon v-else icon="mdi:arrow-right" class="arrow-icon" /> -->
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VScaleScreen>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// Variables - 保持一致
|
||||
$primary-color: #4db6ac;
|
||||
$secondary-color: #333333;
|
||||
$accent-color: #d4af37;
|
||||
$text-color: #004d40;
|
||||
@import '@/styles/scss/variables.scss';
|
||||
|
||||
.title-section {
|
||||
margin: 10px auto;
|
||||
width: 405px;
|
||||
height: 150px;
|
||||
|
||||
.scroll-bg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background: url('@/assets/imgs/user/title-bg.svg') no-repeat center center;
|
||||
background-size: 100% 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-bottom: 10px; // 微调垂直位置,因为卷轴可能有视觉重心偏移
|
||||
|
||||
.main-title {
|
||||
font-size: 2.6rem;
|
||||
color: $text-color;
|
||||
margin: 0;
|
||||
letter-spacing: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.competition-layout-wrapper {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background-color: #f0f2f5; // 给一个底色,防止背景图加载前白屏
|
||||
}
|
||||
|
||||
.fixed-bg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
z-index: 0; // 提升 z-index,确保不被底层遮挡
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
padding: 70px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
font-family: 'Noto Serif SC', serif;
|
||||
z-index: 1; // 确保内容在背景之上
|
||||
|
||||
// Default background decoration
|
||||
// Default background decoration (Bottom Wave)
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@ -124,8 +220,8 @@ $text-color: #004d40;
|
||||
.glass-overlay {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
backdrop-filter: blur(2px);
|
||||
border-radius: 30px;
|
||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
@ -154,8 +250,8 @@ $text-color: #004d40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
// justify-content: center; // 让内容决定位置,通常是 margin-top
|
||||
margin: 40px auto;
|
||||
justify-content: center; // 让内容决定位置,通常是 margin-top
|
||||
// margin: 40px auto;
|
||||
z-index: 10;
|
||||
padding-bottom: 60px;
|
||||
width: 100%; // 确保内容宽度
|
||||
@ -167,49 +263,111 @@ $text-color: #004d40;
|
||||
right: 0; // 默认在右下角
|
||||
z-index: 20;
|
||||
|
||||
&.top {
|
||||
bottom: auto;
|
||||
top: 40px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
border: none;
|
||||
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;
|
||||
transition: all 0.1s ease;
|
||||
background: #eb5e55;
|
||||
box-shadow: 0 8px 0 #fcc64f;
|
||||
|
||||
.arrow-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
transform: translateY(-2px);
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
transform: translateY(4px);
|
||||
box-shadow: 0 2px 0 #fcc64f;
|
||||
}
|
||||
|
||||
// 文字按钮通用样式
|
||||
&.text-btn {
|
||||
width: auto;
|
||||
min-width: 180px;
|
||||
padding: 0 30px;
|
||||
border-radius: 40px;
|
||||
|
||||
.btn-text {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&.next-btn {
|
||||
background: #ef5350; // 红色
|
||||
box-shadow: 0 4px 10px rgba(239, 83, 80, 0.4);
|
||||
|
||||
&:hover {
|
||||
background: #e53935;
|
||||
}
|
||||
// 保留原有 next-btn 特定逻辑(如果有)
|
||||
}
|
||||
|
||||
&.back-btn {
|
||||
background: #ffa726; // 橙色/黄色,区分于下一步
|
||||
box-shadow: 0 4px 10px rgba(255, 167, 38, 0.4);
|
||||
// 保持统一风格
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
&.is-disabled {
|
||||
cursor: not-allowed;
|
||||
// opacity: 0.8; // 移除透明度变化
|
||||
// filter: grayscale(0.5); // 移除置灰效果
|
||||
// box-shadow: none; // 保持阴影,或者根据需要调整
|
||||
|
||||
&:hover {
|
||||
background: #fb8c00;
|
||||
transform: none; // 禁止 hover 位移
|
||||
// filter: grayscale(0.5);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: none;
|
||||
// box-shadow: none; // 保持阴影
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Light Theme Style (White bg, Red text/border, Yellow shadow)
|
||||
&.light {
|
||||
.action-btn {
|
||||
background: white;
|
||||
color: #eb5e55;
|
||||
border: 2px solid #eb5e55;
|
||||
box-shadow: 0 8px 0 #fcc64f; // 还原黄色立体阴影
|
||||
|
||||
&:hover {
|
||||
background-color: #fff; // 保持白色
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(4px);
|
||||
box-shadow: 0 2px 0 #fcc64f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Responsive
|
||||
|
||||
@ -19,6 +19,12 @@ export function useRouterPush(inSetup = true) {
|
||||
|
||||
const routerBack = router.back
|
||||
|
||||
/**
|
||||
* 路由跳转
|
||||
*
|
||||
* @param key 路由名称
|
||||
* @param options 路由参数
|
||||
*/
|
||||
async function routerPushByKey(key: RouteKey, options?: App.Global.RouterPushOptions) {
|
||||
const { query, params } = options || {}
|
||||
|
||||
|
||||
@ -82,6 +82,10 @@ const local: App.I18n.Schema = {
|
||||
warning: 'Warning',
|
||||
error: 'Error',
|
||||
followPrimary: 'Follow Primary',
|
||||
unpublished: 'Unpublished',
|
||||
published: 'Published',
|
||||
processing: 'Processing',
|
||||
finished: 'Finished',
|
||||
},
|
||||
themeRadius: {
|
||||
title: 'Theme Radius',
|
||||
@ -228,7 +232,6 @@ const local: App.I18n.Schema = {
|
||||
'404': 'Page Not Found',
|
||||
'500': 'Server Error',
|
||||
'iframe-page': 'Iframe',
|
||||
'home': 'Home',
|
||||
'competition': 'Competition',
|
||||
'question-store': 'Question Store',
|
||||
'template': 'Template',
|
||||
@ -243,6 +246,15 @@ const local: App.I18n.Schema = {
|
||||
'competition_competition-detail': 'Competition Detail',
|
||||
'competition_competition-list': 'Competition List',
|
||||
'dictionary': 'Dictionary',
|
||||
'admin-home': 'Admin Home',
|
||||
'user_analysis': 'User Analysis',
|
||||
'user_cover': 'User Cover',
|
||||
'user_draw': 'User Draw',
|
||||
'user_game': 'User Game',
|
||||
'user_teams': 'User Teams',
|
||||
'user_rules': 'User Rules',
|
||||
'user_groups': 'User Groups',
|
||||
'user_home': 'User Home',
|
||||
},
|
||||
page: {
|
||||
login: {
|
||||
|
||||
@ -82,6 +82,10 @@ const local: App.I18n.Schema = {
|
||||
warning: '警告色',
|
||||
error: '错误色',
|
||||
followPrimary: '跟随主色',
|
||||
unpublished: '未发布',
|
||||
published: '已发布',
|
||||
processing: '处理中',
|
||||
finished: '已完成',
|
||||
},
|
||||
themeRadius: {
|
||||
title: '主题圆角',
|
||||
@ -224,7 +228,6 @@ const local: App.I18n.Schema = {
|
||||
'404': '页面不存在',
|
||||
'500': '服务器错误',
|
||||
'iframe-page': '外链页面',
|
||||
'home': '首页',
|
||||
'competition': '比赛配置',
|
||||
'question-store': '题库',
|
||||
'template': '模板制作',
|
||||
@ -239,6 +242,15 @@ const local: App.I18n.Schema = {
|
||||
'competition_competition-detail': '比赛详情',
|
||||
'competition_competition-list': '比赛列表',
|
||||
'dictionary': '字典管理',
|
||||
'admin-home': '管理员首页',
|
||||
'user_analysis': '用户分析',
|
||||
'user_cover': '用户封面',
|
||||
'user_draw': '用户绘制',
|
||||
'user_game': '用户游戏',
|
||||
'user_teams': '用户队伍',
|
||||
'user_rules': '用户规则',
|
||||
'user_groups': '用户组',
|
||||
'user_home': '用户首页',
|
||||
},
|
||||
page: {
|
||||
login: {
|
||||
|
||||
@ -25,14 +25,18 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
||||
"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"),
|
||||
user_analysis: () => import("@/views/user/analysis/index.vue"),
|
||||
user_cover: () => import("@/views/user/cover/index.vue"),
|
||||
user_draw: () => import("@/views/user/draw/index.vue"),
|
||||
user_game: () => import("@/views/user/game/index.vue"),
|
||||
user_groups: () => import("@/views/user/groups/index.vue"),
|
||||
user_home: () => import("@/views/user/home/index.vue"),
|
||||
user_rules: () => import("@/views/user/rules/index.vue"),
|
||||
user_teams: () => import("@/views/user/teams/index.vue"),
|
||||
};
|
||||
|
||||
@ -104,32 +104,6 @@ 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.blank$view.home',
|
||||
meta: {
|
||||
title: 'home',
|
||||
i18nKey: 'route.home',
|
||||
icon: 'material-symbols:home',
|
||||
order: 0,
|
||||
constant: true,
|
||||
hideInMenu: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'iframe-page',
|
||||
path: '/iframe-page/:url',
|
||||
@ -213,17 +187,6 @@ 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',
|
||||
@ -263,14 +226,103 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
{
|
||||
name: 'user',
|
||||
path: '/user',
|
||||
component: 'layout.blank$view.user',
|
||||
component: 'layout.blank',
|
||||
redirect: '/user/home',
|
||||
meta: {
|
||||
title: 'user',
|
||||
i18nKey: 'route.user',
|
||||
icon: 'material-symbols:account-circle',
|
||||
order: 6,
|
||||
hideInMenu: true,
|
||||
constant: true
|
||||
}
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'user_analysis',
|
||||
path: '/user/analysis',
|
||||
component: 'view.user_analysis',
|
||||
meta: {
|
||||
title: 'user_analysis',
|
||||
i18nKey: 'route.user_analysis',
|
||||
hideInMenu: true,
|
||||
constant: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'user_cover',
|
||||
path: '/user/cover',
|
||||
component: 'view.user_cover',
|
||||
meta: {
|
||||
title: 'user_cover',
|
||||
i18nKey: 'route.user_cover',
|
||||
hideInMenu: true,
|
||||
constant: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'user_draw',
|
||||
path: '/user/draw',
|
||||
component: 'view.user_draw',
|
||||
meta: {
|
||||
title: 'user_draw',
|
||||
i18nKey: 'route.user_draw',
|
||||
hideInMenu: true,
|
||||
constant: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'user_game',
|
||||
path: '/user/game',
|
||||
component: 'view.user_game',
|
||||
meta: {
|
||||
title: 'user_game',
|
||||
i18nKey: 'route.user_game',
|
||||
hideInMenu: true,
|
||||
constant: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'user_groups',
|
||||
path: '/user/groups',
|
||||
component: 'view.user_groups',
|
||||
meta: {
|
||||
title: 'user_groups',
|
||||
i18nKey: 'route.user_groups',
|
||||
hideInMenu: true,
|
||||
constant: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'user_home',
|
||||
path: '/user/home',
|
||||
component: 'view.user_home',
|
||||
meta: {
|
||||
title: 'user_home',
|
||||
i18nKey: 'route.user_home',
|
||||
hideInMenu: true,
|
||||
constant: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'user_rules',
|
||||
path: '/user/rules',
|
||||
component: 'view.user_rules',
|
||||
meta: {
|
||||
title: 'user_rules',
|
||||
i18nKey: 'route.user_rules',
|
||||
hideInMenu: true,
|
||||
constant: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'user_teams',
|
||||
path: '/user/teams',
|
||||
component: 'view.user_teams',
|
||||
meta: {
|
||||
title: 'user_teams',
|
||||
i18nKey: 'route.user_teams',
|
||||
hideInMenu: true,
|
||||
constant: true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@ -172,8 +172,6 @@ const routeMap: RouteMap = {
|
||||
"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)?",
|
||||
"question-store": "/question-store",
|
||||
@ -181,11 +179,18 @@ 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",
|
||||
"user": "/user"
|
||||
"user": "/user",
|
||||
"user_analysis": "/user/analysis",
|
||||
"user_cover": "/user/cover",
|
||||
"user_draw": "/user/draw",
|
||||
"user_game": "/user/game",
|
||||
"user_groups": "/user/groups",
|
||||
"user_home": "/user/home",
|
||||
"user_rules": "/user/rules",
|
||||
"user_teams": "/user/teams"
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -9,6 +9,16 @@ import { transformElegantRoutesToVueRoutes } from '../elegant/transform'
|
||||
* @link https://github.com/reader-starjs/elegant-router?tab=readme-ov-file#custom-route
|
||||
*/
|
||||
const customRoutes: CustomRoute[] = [
|
||||
{
|
||||
name: 'root',
|
||||
path: '/',
|
||||
redirect: '/user/home',
|
||||
meta: {
|
||||
title: 'root',
|
||||
constant: true,
|
||||
hideInMenu: true,
|
||||
},
|
||||
} as unknown as CustomRoute,
|
||||
{
|
||||
name: 'admin',
|
||||
path: '/admin',
|
||||
|
||||
129
apps/admin/src/store/modules/competition.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import type { CompetitionData } from '@/views/user/types'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouterPush } from '@/hooks/common/router'
|
||||
import { AudioController } from '@/utils/audio'
|
||||
import { mockData } from '@/views/user/data/mock'
|
||||
|
||||
export const useCompetitionStore = defineStore('competition', () => {
|
||||
const { routerPushByKey } = useRouterPush()
|
||||
|
||||
// --- State ---
|
||||
const data = ref<CompetitionData>(mockData)
|
||||
const currentStep = ref(0) // Node 索引
|
||||
const subStep = ref(0) // Question 索引
|
||||
const timeLeft = ref(0)
|
||||
const timerInterval = ref<number | null>(null)
|
||||
|
||||
// 音效控制器 (支持自定义音频文件,例如:{ start: '/audio/start.mp3' })
|
||||
const audioController = new AudioController()
|
||||
|
||||
// --- Getters ---
|
||||
const currentNode = computed(() => data.value.nodes[currentStep.value])
|
||||
|
||||
const currentQuestion = computed(() => {
|
||||
const questions = currentNode.value?.questions || []
|
||||
return questions[subStep.value] || questions[0]
|
||||
})
|
||||
|
||||
const isLastQuestionInNode = computed(() => {
|
||||
if (!currentNode.value)
|
||||
return true
|
||||
return subStep.value >= currentNode.value.questions.length - 1
|
||||
})
|
||||
|
||||
const isLastNode = computed(() => {
|
||||
return currentStep.value >= data.value.nodes.length - 1
|
||||
})
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
function initData() {
|
||||
// 这里可以重置或者重新拉取数据
|
||||
// data.value = ...
|
||||
resetProgress()
|
||||
}
|
||||
|
||||
function resetProgress() {
|
||||
currentStep.value = 0
|
||||
subStep.value = 0
|
||||
stopTimer()
|
||||
}
|
||||
|
||||
// --- Audio Helpers ---
|
||||
// 音频相关逻辑已抽离到 @/utils/audio.ts
|
||||
|
||||
/**
|
||||
* 启动倒计时定时器
|
||||
*/
|
||||
function startTimer() {
|
||||
stopTimer()
|
||||
if (!currentNode.value)
|
||||
return
|
||||
|
||||
timeLeft.value = currentNode.value.config.timeLimit
|
||||
audioController.play('start') // 播放开始音效
|
||||
|
||||
timerInterval.value = window.setInterval(() => {
|
||||
if (timeLeft.value > 0) {
|
||||
timeLeft.value--
|
||||
// 剩余5秒播放倒计时音效
|
||||
if (timeLeft.value <= 5) {
|
||||
audioController.play('tick')
|
||||
}
|
||||
}
|
||||
else {
|
||||
stopTimer()
|
||||
// 时间到,自动进入解析页
|
||||
routerPushByKey('user_analysis')
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timerInterval.value) {
|
||||
clearInterval(timerInterval.value)
|
||||
timerInterval.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入下一题或下一环节
|
||||
* @returns 返回下一步的路由名称,方便组件跳转
|
||||
*/
|
||||
function nextStep() {
|
||||
stopTimer()
|
||||
|
||||
// A. 如果当前环节还有题
|
||||
if (!isLastQuestionInNode.value) {
|
||||
subStep.value++
|
||||
// 回到抽题页
|
||||
return 'user_draw'
|
||||
}
|
||||
// B. 当前环节结束,去下一个环节
|
||||
else if (!isLastNode.value) {
|
||||
currentStep.value++
|
||||
subStep.value = 0
|
||||
// 回到抽题页(新环节)
|
||||
return 'user_draw'
|
||||
}
|
||||
// C. 全部结束
|
||||
else {
|
||||
resetProgress()
|
||||
return 'user_cover'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
currentStep,
|
||||
subStep,
|
||||
timeLeft,
|
||||
currentNode,
|
||||
currentQuestion,
|
||||
initData,
|
||||
startTimer,
|
||||
stopTimer,
|
||||
nextStep,
|
||||
}
|
||||
})
|
||||
12
apps/admin/src/styles/scss/variables.scss
Normal file
@ -0,0 +1,12 @@
|
||||
// Brand Colors
|
||||
$primary-color: #4db6ac;
|
||||
$secondary-color: #333333;
|
||||
$accent-color: #d4af37;
|
||||
|
||||
// Text Colors
|
||||
$text-color: #004d40;
|
||||
$group-text-color: #5d4037;
|
||||
|
||||
// Backgrounds
|
||||
$card-bg: #b2dfdb;
|
||||
$tag-bg: #d7ccc8;
|
||||
6
apps/admin/src/typings/components.d.ts
vendored
@ -25,6 +25,7 @@ declare module 'vue' {
|
||||
IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-outlined')['default']
|
||||
IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
|
||||
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
||||
'IconIc:baselineArrowRightAlt': typeof import('~icons/ic/baseline-arrow-right-alt')['default']
|
||||
'IconIc:baselineDeleteForever': typeof import('~icons/ic/baseline-delete-forever')['default']
|
||||
'IconIc:roundPublish': typeof import('~icons/ic/round-publish')['default']
|
||||
IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['default']
|
||||
@ -58,6 +59,8 @@ declare module 'vue' {
|
||||
IconIcOutlineTitle: typeof import('~icons/ic/outline-title')['default']
|
||||
IconIcRoundAccessTime: typeof import('~icons/ic/round-access-time')['default']
|
||||
IconIcRoundArrowBack: typeof import('~icons/ic/round-arrow-back')['default']
|
||||
IconIcRoundArrowLeft: typeof import('~icons/ic/round-arrow-left')['default']
|
||||
IconIcRoundArrowRight: typeof import('~icons/ic/round-arrow-right')['default']
|
||||
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
|
||||
IconIcRoundLibraryAdd: typeof import('~icons/ic/round-library-add')['default']
|
||||
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
|
||||
@ -190,6 +193,7 @@ declare global {
|
||||
const IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-outlined')['default']
|
||||
const IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
|
||||
const IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
||||
const 'IconIc:baselineArrowRightAlt': typeof import('~icons/ic/baseline-arrow-right-alt')['default']
|
||||
const 'IconIc:baselineDeleteForever': typeof import('~icons/ic/baseline-delete-forever')['default']
|
||||
const 'IconIc:roundPublish': typeof import('~icons/ic/round-publish')['default']
|
||||
const IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['default']
|
||||
@ -223,6 +227,8 @@ declare global {
|
||||
const IconIcOutlineTitle: typeof import('~icons/ic/outline-title')['default']
|
||||
const IconIcRoundAccessTime: typeof import('~icons/ic/round-access-time')['default']
|
||||
const IconIcRoundArrowBack: typeof import('~icons/ic/round-arrow-back')['default']
|
||||
const IconIcRoundArrowLeft: typeof import('~icons/ic/round-arrow-left')['default']
|
||||
const IconIcRoundArrowRight: typeof import('~icons/ic/round-arrow-right')['default']
|
||||
const IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
|
||||
const IconIcRoundLibraryAdd: typeof import('~icons/ic/round-library-add')['default']
|
||||
const IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
|
||||
|
||||
26
apps/admin/src/typings/elegant-router.d.ts
vendored
@ -26,8 +26,6 @@ declare module "@elegant-router/types" {
|
||||
"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)?";
|
||||
"question-store": "/question-store";
|
||||
@ -35,11 +33,18 @@ 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";
|
||||
"user": "/user";
|
||||
"user_analysis": "/user/analysis";
|
||||
"user_cover": "/user/cover";
|
||||
"user_draw": "/user/draw";
|
||||
"user_game": "/user/game";
|
||||
"user_groups": "/user/groups";
|
||||
"user_home": "/user/home";
|
||||
"user_rules": "/user/rules";
|
||||
"user_teams": "/user/teams";
|
||||
};
|
||||
|
||||
/**
|
||||
@ -77,14 +82,11 @@ declare module "@elegant-router/types" {
|
||||
| "admin-home"
|
||||
| "competition"
|
||||
| "dictionary"
|
||||
| "groups"
|
||||
| "home"
|
||||
| "iframe-page"
|
||||
| "login"
|
||||
| "question-store"
|
||||
| "rank"
|
||||
| "results"
|
||||
| "teams"
|
||||
| "template"
|
||||
| "user"
|
||||
>;
|
||||
@ -113,16 +115,20 @@ declare module "@elegant-router/types" {
|
||||
| "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"
|
||||
| "user_analysis"
|
||||
| "user_cover"
|
||||
| "user_draw"
|
||||
| "user_game"
|
||||
| "user_groups"
|
||||
| "user_home"
|
||||
| "user_rules"
|
||||
| "user_teams"
|
||||
>;
|
||||
|
||||
/**
|
||||
|
||||
121
apps/admin/src/utils/audio.ts
Normal file
@ -0,0 +1,121 @@
|
||||
export class AudioController {
|
||||
private audioContext: AudioContext | null = null
|
||||
private soundFiles: Record<string, string> = {}
|
||||
private audioCache: Record<string, HTMLAudioElement> = {}
|
||||
|
||||
constructor(soundFiles: Record<string, string> = {}) {
|
||||
this.soundFiles = soundFiles
|
||||
}
|
||||
|
||||
private initAudioContext() {
|
||||
if (!this.audioContext) {
|
||||
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放指定类型的音效
|
||||
* 优先使用预设的音频文件,如果没有配置或加载失败,则使用 Web Audio API 合成音效
|
||||
*/
|
||||
async play(type: 'start' | 'tick' | 'flip') {
|
||||
// 尝试播放音频文件
|
||||
if (this.soundFiles[type]) {
|
||||
try {
|
||||
await this.playFile(type)
|
||||
return
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(`Failed to play audio file for ${type}, falling back to synth.`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到合成音效
|
||||
this.playSynth(type)
|
||||
}
|
||||
|
||||
private playFile(type: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = this.soundFiles[type]
|
||||
if (!url) {
|
||||
reject(new Error('No file url'))
|
||||
return
|
||||
}
|
||||
|
||||
// 使用缓存的 Audio 对象
|
||||
if (!this.audioCache[type]) {
|
||||
this.audioCache[type] = new Audio(url)
|
||||
}
|
||||
|
||||
const audio = this.audioCache[type]
|
||||
audio.currentTime = 0
|
||||
audio.play()
|
||||
.then(() => resolve())
|
||||
.catch(e => reject(e))
|
||||
})
|
||||
}
|
||||
|
||||
private playSynth(type: 'start' | 'tick' | 'flip') {
|
||||
this.initAudioContext()
|
||||
if (!this.audioContext)
|
||||
return
|
||||
|
||||
const ctxTime = this.audioContext.currentTime
|
||||
const gain = this.audioContext.createGain()
|
||||
gain.connect(this.audioContext.destination)
|
||||
|
||||
if (type === 'start') {
|
||||
const osc = this.audioContext.createOscillator()
|
||||
osc.connect(gain)
|
||||
// 开始音效:高音短促提示
|
||||
osc.type = 'sine'
|
||||
osc.frequency.setValueAtTime(880, ctxTime)
|
||||
osc.frequency.exponentialRampToValueAtTime(440, ctxTime + 0.3)
|
||||
gain.gain.setValueAtTime(0.5, ctxTime)
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctxTime + 0.3)
|
||||
osc.start(ctxTime)
|
||||
osc.stop(ctxTime + 0.3)
|
||||
}
|
||||
else if (type === 'tick') {
|
||||
const osc = this.audioContext.createOscillator()
|
||||
osc.connect(gain)
|
||||
// 倒计时音效:急促的滴答声
|
||||
osc.type = 'triangle'
|
||||
osc.frequency.setValueAtTime(600, ctxTime)
|
||||
gain.gain.setValueAtTime(0.3, ctxTime)
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctxTime + 0.1)
|
||||
osc.start(ctxTime)
|
||||
osc.stop(ctxTime + 0.1)
|
||||
}
|
||||
else if (type === 'flip') {
|
||||
// 翻书音效:使用白噪声模拟纸张摩擦
|
||||
const bufferSize = this.audioContext.sampleRate * 0.5 // 0.5秒缓冲
|
||||
const buffer = this.audioContext.createBuffer(1, bufferSize, this.audioContext.sampleRate)
|
||||
const data = buffer.getChannelData(0)
|
||||
|
||||
// 生成白噪声
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
data[i] = Math.random() * 2 - 1
|
||||
}
|
||||
|
||||
const noise = this.audioContext.createBufferSource()
|
||||
noise.buffer = buffer
|
||||
|
||||
// 滤波器:低通滤波器,模拟纸张的闷声
|
||||
const filter = this.audioContext.createBiquadFilter()
|
||||
filter.type = 'lowpass'
|
||||
filter.frequency.setValueAtTime(400, ctxTime)
|
||||
filter.frequency.exponentialRampToValueAtTime(3000, ctxTime + 0.1) // 频率快速扫过,模拟快速翻动
|
||||
|
||||
noise.connect(filter)
|
||||
filter.connect(gain)
|
||||
|
||||
// 音量包络:快速淡入淡出
|
||||
gain.gain.setValueAtTime(0, ctxTime)
|
||||
gain.gain.linearRampToValueAtTime(0.8, ctxTime + 0.05)
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctxTime + 0.3)
|
||||
|
||||
noise.start(ctxTime)
|
||||
noise.stop(ctxTime + 0.3)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -44,7 +44,7 @@ const statusConfig = computed(() => {
|
||||
// const formattedTitle = computed(() => props.item.title.replace(/\n/g, '<br/>'))
|
||||
|
||||
function toDetail(item: CompetitionItem) {
|
||||
routerPushByKey('competition_competition-detail', { query: { Id: item.Id } })
|
||||
routerPushByKey('competition_competition-detail', { query: { Id: item.Id.toString() } })
|
||||
}
|
||||
|
||||
// 随机背景色池
|
||||
|
||||
165
apps/admin/src/views/user/analysis/index.vue
Normal file
@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
||||
import { useRouterPush } from '@/hooks/common/router'
|
||||
import { useCompetitionStore } from '@/store/modules/competition'
|
||||
|
||||
const { routerPushByKey } = useRouterPush()
|
||||
|
||||
const store = useCompetitionStore()
|
||||
|
||||
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',
|
||||
},
|
||||
{
|
||||
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
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
routerPushByKey('user_game')
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
const nextRoute = store.nextStep()
|
||||
routerPushByKey(nextRoute)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CompetitionLayout
|
||||
:show-title="true"
|
||||
:show-back="true"
|
||||
:show-next="true"
|
||||
title="答题图解"
|
||||
action-position="top"
|
||||
back-btn-text="返回"
|
||||
next-btn-text="下一题"
|
||||
btn-theme="light"
|
||||
@back="handleBack"
|
||||
@next="handleNext"
|
||||
>
|
||||
<div class="relative h-full w-full flex flex-col items-center px-12 pt-10 font-sans">
|
||||
<!-- 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>
|
||||
</CompetitionLayout>
|
||||
</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>
|
||||
284
apps/admin/src/views/user/cover/index.vue
Normal file
@ -0,0 +1,284 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
||||
import { useRouterPush } from '@/hooks/common/router'
|
||||
import { AudioController } from '@/utils/audio'
|
||||
import { mockData } from '../data/mock'
|
||||
|
||||
const { routerPushByKey } = useRouterPush()
|
||||
const audioController = new AudioController()
|
||||
|
||||
const activityName = ref('星·辞海遨游')
|
||||
|
||||
// 记录已翻转的卡片索引
|
||||
const flippedCards = ref<Set<number>>(new Set())
|
||||
|
||||
// 计算是否所有卡片都已翻转 (当前显示4张卡片)
|
||||
const isAllFlipped = computed(() => flippedCards.value.size === 4)
|
||||
|
||||
// 控制下一步按钮的显示(带延迟)
|
||||
const showNextButton = ref(false)
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(isAllFlipped, (val) => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
if (val) {
|
||||
timer = setTimeout(() => {
|
||||
showNextButton.value = true
|
||||
}, 1500)
|
||||
}
|
||||
else {
|
||||
showNextButton.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function handleBack() {
|
||||
routerPushByKey('user_teams')
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
routerPushByKey('user_rules')
|
||||
}
|
||||
|
||||
function toggleFlip(index: number) {
|
||||
if (flippedCards.value.has(index)) {
|
||||
flippedCards.value.delete(index) // 可选:如果希望再次点击翻回去,可以取消注释
|
||||
}
|
||||
else {
|
||||
flippedCards.value.add(index)
|
||||
audioController.play('flip') // 播放翻牌音效
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CompetitionLayout :show-back="true" :show-next="showNextButton" :title="activityName" @back="handleBack" @next="handleNext">
|
||||
<div class="h-screen w-full overflow-hidden font-sans">
|
||||
<div class="relative z-10 h-full w-full">
|
||||
<div class="h-full w-full flex flex-col items-center pt-24">
|
||||
<!-- 卡片列表 -->
|
||||
<div class="perspective-container grid grid-cols-4 max-w-7xl w-full gap-10 px-12">
|
||||
<div
|
||||
v-for="(node, index) in mockData.nodes.slice(0, 4)"
|
||||
:key="node.nodeId"
|
||||
class="card-wrapper"
|
||||
@click="toggleFlip(index)"
|
||||
>
|
||||
<div class="flip-card-inner" :class="{ 'is-flipped': flippedCards.has(index) }">
|
||||
<!-- 正面 (Front) - 显示背面图案 -->
|
||||
<div class="flip-card-front card-face">
|
||||
<!-- 这里是未翻开时的样子,即卡片背面 -->
|
||||
<div class="card-back-design" />
|
||||
</div>
|
||||
|
||||
<!-- 背面 (Back) - 显示内容 -->
|
||||
<div class="flip-card-back card-face">
|
||||
<!-- 这里是翻开后的样子,即卡片正面内容 -->
|
||||
<div class="card-content-design">
|
||||
<!-- 内部装饰圈 -->
|
||||
<div class="inner-circle-decoration">
|
||||
<!-- 文字内容 -->
|
||||
<!-- <div class="relative z-10 transform text-center">
|
||||
<div class="card-text text-3xl text-[#5d4037] 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CompetitionLayout>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@/styles/scss/variables.scss';
|
||||
|
||||
.perspective-container {
|
||||
perspective: 1000px; // [3D透视] 设置观察者距离,数值越小透视感越强,产生近大远小的3D效果
|
||||
}
|
||||
|
||||
.card-wrapper {
|
||||
position: relative;
|
||||
aspect-ratio: 3/4;
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-10px); // [悬停效果] 鼠标移上去时卡片轻微上浮
|
||||
}
|
||||
}
|
||||
|
||||
.flip-card-inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
// [动画核心]
|
||||
// 0.6s: 动画持续时间
|
||||
// cubic-bezier(...): 贝塞尔曲线,(0.175, 0.885, 0.32, 1.275) 这是一个带有"回弹"效果的曲线
|
||||
// 也就是卡片翻过去时会稍微过头一点再弹回来,增加生动感
|
||||
transition: transform 3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
|
||||
transform-style: preserve-3d; // [3D空间] 确保子元素(正反面)在3D空间中渲染,而不是压平在平面上
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.2); // [静态阴影] 默认状态下的投影
|
||||
|
||||
&.is-flipped {
|
||||
// [翻转状态]
|
||||
// rotateY(180deg): 绕Y轴旋转180度,实现翻面
|
||||
// scale(1.05): 翻转同时放大1.05倍,产生"向观众逼近"的视觉冲击力
|
||||
transform: rotateY(180deg) scale(1.06);
|
||||
box-shadow: 0 20px 40px -5px rgba(0, 0, 0, 0.3); // [动态阴影] 翻转浮起时阴影更深、更扩散
|
||||
}
|
||||
}
|
||||
|
||||
.card-face {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
// [背面隐藏] 关键属性!当元素背面朝向观察者时隐藏。
|
||||
// 这样保证翻转180度后,正面消失,背面显示(或者反之)
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5); // 增加微妙的边框
|
||||
}
|
||||
|
||||
// 正面(实际是卡背图案)
|
||||
.flip-card-front {
|
||||
background-color: transparent;
|
||||
|
||||
.card-back-design {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: url('@/assets/imgs/user/cover-back.svg') no-repeat center center; // 背景面
|
||||
background-size: contain; // 保持比例
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: filter 1s; // [滤镜动画] 1秒内变化,模拟卡片翻转时的光影变化
|
||||
}
|
||||
}
|
||||
|
||||
// 背面(实际是内容面)
|
||||
.flip-card-back {
|
||||
// background-color: #fff;
|
||||
transform: rotateY(180deg);
|
||||
// border: 4px solid #fff;
|
||||
|
||||
.card-content-design {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: url('@/assets/imgs/user/cover-bg-active.svg') no-repeat center center;
|
||||
background-size: cover;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
|
||||
// 光效叠加
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -150%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: linear-gradient(
|
||||
to bottom right,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0) 40%,
|
||||
rgba(255, 255, 255, 0.6) 50%,
|
||||
rgba(255, 255, 255, 0) 60%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
transform: rotate(-30deg);
|
||||
opacity: 0;
|
||||
transition: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
// 翻转后的高光效果 - 扫光动画
|
||||
.is-flipped & .card-content-design::before {
|
||||
// [扫光动画触发]
|
||||
// animation-delay: 0.2s; 确保卡片翻转到一半(正对屏幕)时才开始闪光,
|
||||
// 模拟光线随着角度变化掠过卡片表面的物理效果
|
||||
animation: shine-sweep 3s ease-out forwards;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
}
|
||||
|
||||
.inner-circle-decoration {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid $accent-color; // 使用金色
|
||||
// box-shadow: 0 0 15px rgba(212, 175, 55, 0.2);
|
||||
position: relative;
|
||||
|
||||
// 祥云纹理
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
left: -5px;
|
||||
right: -5px;
|
||||
bottom: -5px;
|
||||
border-radius: 50%;
|
||||
border: 1px dashed $accent-color;
|
||||
animation: spin 20s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.card-text {
|
||||
font-family: 'Noto Serif SC', serif;
|
||||
color: $group-text-color;
|
||||
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
// [扫光动画关键帧]
|
||||
// 模拟一道光束从左上角快速扫向右下角
|
||||
@keyframes shine-sweep {
|
||||
0% {
|
||||
top: -150%;
|
||||
left: -150%;
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
// 刚进入时瞬间变亮
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
// 扫出视野
|
||||
top: 50%;
|
||||
left: 150%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,5 +1,5 @@
|
||||
import type { CompetitionData } from '../types'
|
||||
import img from '@/assets/imgs/q5-img.png'
|
||||
import img from '@/assets/imgs/user/q5-img.png'
|
||||
|
||||
/**
|
||||
* 模拟数据
|
||||
|
||||
41
apps/admin/src/views/user/draw/index.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
||||
import { useRouterPush } from '@/hooks/common/router'
|
||||
import { useCompetitionStore } from '@/store/modules/competition'
|
||||
|
||||
const { routerPushByKey } = useRouterPush()
|
||||
|
||||
const store = useCompetitionStore()
|
||||
|
||||
const currentNode = computed(() => store.currentNode)
|
||||
function handleDraw() {
|
||||
routerPushByKey('user_game')
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
routerPushByKey('user_cover')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CompetitionLayout :show-back="true" :show-next="false" :title="currentNode?.moduleName || '抽题'" @back="handleBack">
|
||||
<div class="h-full w-full flex flex-col items-center pt-10 font-sans">
|
||||
<div v-if="currentNode" class="mb-20 max-w-4xl text-center">
|
||||
<div class="text-2xl text-gray-800 font-medium leading-relaxed tracking-wide">
|
||||
{{ currentNode.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="handleDraw"
|
||||
>
|
||||
<span>抽题</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CompetitionLayout>
|
||||
</template>
|
||||
152
apps/admin/src/views/user/game/index.vue
Normal file
@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
||||
import { useRouterPush } from '@/hooks/common/router'
|
||||
import { useCompetitionStore } from '@/store/modules/competition'
|
||||
// import { uiTemplatesMapping } from '@/views/user/data/mock'
|
||||
import QuestionRenderer from '../modules/QuestionRenderer.vue'
|
||||
|
||||
const { routerPushByKey } = useRouterPush()
|
||||
|
||||
const store = useCompetitionStore()
|
||||
|
||||
// 本地倒计时控制(为了在页面上显示“开始答题”还是倒计时)
|
||||
const isStarted = ref(false)
|
||||
|
||||
const currentNode = computed(() => store.currentNode)
|
||||
const currentQuestion = computed(() => store.currentQuestion)
|
||||
const timeLeft = computed(() => store.timeLeft)
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
const m = Math.floor(timeLeft.value / 60)
|
||||
const s = timeLeft.value % 60
|
||||
const mm = m < 10 ? `0${m}` : m
|
||||
const ss = s < 10 ? `0${s}` : s
|
||||
return `倒计时 ${mm}:${ss}`
|
||||
})
|
||||
|
||||
// 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 },
|
||||
]
|
||||
|
||||
function handleBack() {
|
||||
store.stopTimer()
|
||||
routerPushByKey('user_draw')
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (!isStarted.value) {
|
||||
// 点击开始答题
|
||||
isStarted.value = true
|
||||
store.startTimer()
|
||||
}
|
||||
else {
|
||||
// 答题中,点击直接进入下一页(或者也可以设计为暂停等,这里按原逻辑是直接跳过)
|
||||
store.stopTimer()
|
||||
routerPushByKey('user_analysis')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CompetitionLayout
|
||||
action-position="top"
|
||||
:show-back="true"
|
||||
:show-next="true"
|
||||
:title="currentNode?.moduleName || ''"
|
||||
back-btn-text="返回"
|
||||
:next-btn-text="isStarted ? formattedTime : '开始答题'"
|
||||
:next-disabled="isStarted"
|
||||
btn-theme="light"
|
||||
@back="handleBack"
|
||||
@next="handleNext"
|
||||
>
|
||||
<div class="relative h-full w-full flex flex-col items-center px-12 font-sans">
|
||||
<!-- 题目说明 -->
|
||||
<!-- <div v-if="currentNode" class="m-2 text-2xl text-gray-800 font-medium">
|
||||
{{ currentNode.description }}
|
||||
</div> -->
|
||||
|
||||
<!-- 题目内容区域 -->
|
||||
<div v-if="currentNode && currentQuestion" class="flex-2 mb-4 mt-12 w-full flex items-center justify-center">
|
||||
<QuestionRenderer
|
||||
:template="currentNode.uiTemplate" :content="currentQuestion.content"
|
||||
class="origin-center scale-150 transform"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 隐藏的 Next 按钮 (方便演示:右上角小区域) -->
|
||||
<div class="absolute right-0 top-0 z-50 h-20 w-20 cursor-pointer" title="Next Step (Debug)" @click="handleNext">
|
||||
<div class="h-full w-full flex items-center justify-center">
|
||||
<div class="h-6 w-6 rotate-45 bg-blue-500">
|
||||
<icon-ic:baseline-arrow-right-alt class="text-icon text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部图表区域 -->
|
||||
<div
|
||||
class="relative z-20 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>
|
||||
</CompetitionLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Question Renderer Override if needed */
|
||||
:deep(.question-content) {
|
||||
font-size: 4rem;
|
||||
color: #ef4444;
|
||||
/* red-500 */
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
@ -1,9 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
||||
import { useRouterPush } from '@/hooks/common/router'
|
||||
|
||||
const router = useRouter()
|
||||
const { routerPushByKey } = useRouterPush()
|
||||
|
||||
interface GroupItem {
|
||||
id: number
|
||||
@ -27,16 +27,16 @@ const groups = ref<GroupItem[]>([
|
||||
const showContent = ref(false)
|
||||
|
||||
function handleBack() {
|
||||
router.push('/home')
|
||||
routerPushByKey('user_home')
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
router.push('/teams')
|
||||
routerPushByKey('user_teams')
|
||||
}
|
||||
|
||||
function selectGroup(_id: number) {
|
||||
// 选择组别逻辑
|
||||
router.push('/teams')
|
||||
routerPushByKey('user_teams', { query: { id: _id.toString() } })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@ -50,20 +50,10 @@ onMounted(() => {
|
||||
<CompetitionLayout
|
||||
:show-back="true"
|
||||
:show-next="false"
|
||||
title="展示组别"
|
||||
@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">
|
||||
@ -78,7 +68,7 @@ onMounted(() => {
|
||||
<div class="icon-wrapper">
|
||||
<div class="flower-bg" />
|
||||
<div class="inner-icon">
|
||||
<img src="@/assets/imgs/star-icon.svg" alt="star" class="star-img">
|
||||
<img src="@/assets/imgs/user/star-icon.svg" alt="star" class="star-img">
|
||||
</div>
|
||||
</div>
|
||||
<div class="group-name-tag">
|
||||
@ -92,25 +82,7 @@ onMounted(() => {
|
||||
</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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@import '@/styles/scss/variables.scss';
|
||||
|
||||
.groups-container {
|
||||
width: 100%;
|
||||
@ -168,7 +140,7 @@ $tag-bg: #d7ccc8;
|
||||
padding: 4px 16px;
|
||||
border-radius: 16px;
|
||||
font-size: 1rem;
|
||||
color: #5d4037;
|
||||
color: $group-text-color;
|
||||
font-weight: bold;
|
||||
position: relative;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
@ -1,9 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
||||
import { useRouterPush } from '@/hooks/common/router'
|
||||
|
||||
const router = useRouter()
|
||||
const { routerPushByKey } = useRouterPush()
|
||||
|
||||
interface EventItem {
|
||||
id: number
|
||||
@ -20,9 +20,8 @@ const events = ref<EventItem[]>([
|
||||
{ id: 4, title: '第九届友谊赛', subTitle: '', icon: 'heart', status: 'finished' },
|
||||
])
|
||||
|
||||
function handleEnter(_id: number) {
|
||||
// router.push(`/competition/detail/${id}`);
|
||||
router.push('/groups')
|
||||
function handleEnter(id: number) {
|
||||
routerPushByKey('user_groups', { query: { id: id.toString() } })
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
@ -31,7 +30,7 @@ function handleBack() {
|
||||
|
||||
function handleNext() {
|
||||
// 首页的下一步操作,可以根据需求实现
|
||||
router.push('/groups')
|
||||
routerPushByKey('user_groups')
|
||||
}
|
||||
|
||||
const showContent = ref(false)
|
||||
@ -46,30 +45,19 @@ onMounted(() => {
|
||||
<CompetitionLayout
|
||||
:show-back="false"
|
||||
:show-next="false"
|
||||
title="赛事总览"
|
||||
action-position="top"
|
||||
back-btn-text="返回"
|
||||
btn-theme="light"
|
||||
@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)"
|
||||
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">
|
||||
@ -93,37 +81,11 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@import '@/styles/scss/variables.scss';
|
||||
|
||||
.cards-container {
|
||||
width: 100%;
|
||||
padding: 0 40px;
|
||||
margin-top: 115px;
|
||||
}
|
||||
|
||||
.cards-wrapper {
|
||||
@ -136,7 +98,7 @@ $card-bg: #b2dfdb;
|
||||
.event-card {
|
||||
width: 340px;
|
||||
height: 480px;
|
||||
background: url('@/assets/imgs/event-card-bg.svg') no-repeat center center;
|
||||
background: url('@/assets/imgs/user/event-card-bg.svg') no-repeat center center;
|
||||
border-radius: 8px 8px 40px 40px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
@ -1,152 +0,0 @@
|
||||
<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' // 封面组件
|
||||
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>
|
||||
<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>
|
||||
</CompetitionLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@ -7,24 +7,6 @@ defineEmits(['back', 'draw'])
|
||||
|
||||
<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 }}
|
||||
@ -40,24 +22,5 @@ defineEmits(['back', '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>
|
||||
|
||||
@ -16,10 +16,10 @@ const pinyinChars = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-[400px] w-full flex items-center justify-center p-10">
|
||||
<div class="min-h-[400px] w-full flex items-center justify-center p-6">
|
||||
<!-- 模板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);">
|
||||
<div class="mb-6 inline-block text-5xl 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">
|
||||
@ -91,7 +91,7 @@ const pinyinChars = computed(() => {
|
||||
|
||||
<!-- 模板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">
|
||||
<div class="mb-1 text-2xl text-gray-800 font-bold leading-relaxed">
|
||||
{{ content.title }}
|
||||
</div>
|
||||
<div class="options-container">
|
||||
@ -153,8 +153,8 @@ const pinyinChars = computed(() => {
|
||||
margin-bottom: 3rem;
|
||||
|
||||
.option-card {
|
||||
width: 320px;
|
||||
height: 260px;
|
||||
// width: 320px;
|
||||
// height: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -165,7 +165,6 @@ const pinyinChars = computed(() => {
|
||||
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;
|
||||
|
||||
@ -188,8 +187,8 @@ const pinyinChars = computed(() => {
|
||||
}
|
||||
|
||||
.option-image-wrapper {
|
||||
margin-bottom: 1rem;
|
||||
height: 9rem;
|
||||
margin-bottom: 0.8rem;
|
||||
height: 6rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
80
apps/admin/src/views/user/rules/index.vue
Normal file
@ -0,0 +1,80 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
||||
import { useRouterPush } from '@/hooks/common/router'
|
||||
|
||||
const { routerPushByKey } = useRouterPush()
|
||||
|
||||
const activityName = ref('规则介绍')
|
||||
const rulesContent = ref('')
|
||||
|
||||
const mockContent = `<p>本轮环节共有4道题目,分别是“<strong>诗词理解</strong>”、“<strong>联想对对碰</strong>”、“<strong>逆向接诗句</strong>”、“<strong>情景猜诗句</strong>”</p><p>阅读完题目介绍,主持人点击“<strong>开始作答</strong>”后均在答题本田字格上作答</p><p><br></p><p> “<strong>诗词理解</strong>” 根据题目选择正确答案</p><p> “<strong>联想对对碰</strong>” 根据关键信息写出完整诗句</p><p> “<strong>逆向接诗句</strong>” 根据关键信息写出关联诗句</p><p> “<strong>情景猜诗句</strong>” 根据情景写出关联诗句</p><p><br></p><p><span style=\"background-color: rgb(89, 191, 192);\">答题倒计时结束后,界面自动跳转到下一题</span></p>
|
||||
`
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
rulesContent.value = mockContent
|
||||
}, 100)
|
||||
})
|
||||
|
||||
function handleBack() {
|
||||
routerPushByKey('user_cover')
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
routerPushByKey('user_draw')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CompetitionLayout
|
||||
:show-back="true"
|
||||
:show-next="true"
|
||||
:title="activityName"
|
||||
@back="handleBack"
|
||||
@next="handleNext"
|
||||
>
|
||||
<div class="items-flex-start h-full w-full flex justify-center font-sans">
|
||||
<div class="rules-content-wrapper">
|
||||
<div class="rules-container" v-html="rulesContent" />
|
||||
</div>
|
||||
</div>
|
||||
</CompetitionLayout>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/scss/variables.scss';
|
||||
|
||||
.rules-content-wrapper {
|
||||
width: 100%;
|
||||
max-height: 80%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $text-color;
|
||||
|
||||
:deep(.rules-container) {
|
||||
margin-top: 10px;
|
||||
font-size: 28px;
|
||||
line-height: 2;
|
||||
// color: #5d4037; // Dark brown/grey text
|
||||
|
||||
.intro-text {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.rules-list {
|
||||
margin: 40px 0;
|
||||
|
||||
p {
|
||||
margin: 15px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
margin-top: 40px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,9 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CompetitionLayout from '@/components/custom/user/CompetitionLayout.vue'
|
||||
import { useRouterPush } from '@/hooks/common/router'
|
||||
|
||||
const router = useRouter()
|
||||
const { routerPushByKey } = useRouterPush()
|
||||
|
||||
interface TeamItem {
|
||||
id: number
|
||||
@ -21,12 +21,17 @@ const teams = ref<TeamItem[]>([
|
||||
const showContent = ref(false)
|
||||
|
||||
function handleBack() {
|
||||
router.push('/groups')
|
||||
routerPushByKey('user_groups')
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
// 进入用户主路由
|
||||
router.push('/user')
|
||||
// 进入规则页面
|
||||
routerPushByKey('user_rules')
|
||||
}
|
||||
|
||||
function selectTeam(_id: number) {
|
||||
// 选择队伍逻辑
|
||||
routerPushByKey('user_cover', { query: { id: _id.toString() } })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@ -37,39 +42,30 @@ onMounted(() => {
|
||||
</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>
|
||||
|
||||
<CompetitionLayout :show-back="true" :show-next="false" :show-title="false" @back="handleBack" @next="handleNext">
|
||||
<!-- Scroll Content -->
|
||||
<div class="scroll-container">
|
||||
<div class="scroll-body">
|
||||
<div class="scroll-left-decor" />
|
||||
<!-- Title Section inside Scroll -->
|
||||
<Transition name="fade-slide-down" appear>
|
||||
<div v-if="showContent" class="title-section">
|
||||
<div class="scroll-bg">
|
||||
<span class="main-title text-4xl text-#22685a">
|
||||
队伍名单
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<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` }"
|
||||
@click="selectTeam(item.id)"
|
||||
>
|
||||
<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>
|
||||
<img src="@/assets/imgs/user/star-icon.svg" alt="team-icon" class="h-full w-full object-cover">
|
||||
</div>
|
||||
<div class="team-name">
|
||||
{{ item.name }}
|
||||
@ -86,24 +82,18 @@ onMounted(() => {
|
||||
<style lang="scss" scoped>
|
||||
.title-section {
|
||||
position: absolute;
|
||||
top: 100px; // 调整位置,可能是在卷轴上方或者左侧
|
||||
left: 50px; // 假设竖排标题
|
||||
top: 18%; // 使用百分比定位,适应不同比例
|
||||
left: 7%; // 微调位置,根据 1920x1080 下的视觉效果调整
|
||||
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;
|
||||
letter-spacing: 20px;
|
||||
text-align: center;
|
||||
font-weight: 900;
|
||||
font-family: 'KaiTi', 'STKaiti', serif; // 尝试使用楷体增加古风感
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -123,20 +113,17 @@ onMounted(() => {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 1px solid red;
|
||||
// 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: url('@/assets/imgs/user/team-bg.svg') no-repeat center center;
|
||||
background-size: 100% 100%;
|
||||
|
||||
&::before {
|
||||
@ -177,47 +164,6 @@ onMounted(() => {
|
||||
// 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 {
|
||||
@ -236,7 +182,7 @@ onMounted(() => {
|
||||
.fade-slide-down-enter-from,
|
||||
.fade-slide-down-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px); // 竖排标题从左侧进入
|
||||
transform: translateX(-70px); // 竖排标题从左侧进入
|
||||
}
|
||||
|
||||
.list-anim-enter-active,
|
||||
12
pnpm-lock.yaml
generated
@ -117,6 +117,9 @@ importers:
|
||||
tailwind-merge:
|
||||
specifier: 3.4.0
|
||||
version: 3.4.0
|
||||
v-scale-screen:
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0(vue@3.5.26(typescript@5.9.3))
|
||||
vue:
|
||||
specifier: 3.5.26
|
||||
version: 3.5.26(typescript@5.9.3)
|
||||
@ -5228,6 +5231,11 @@ packages:
|
||||
resolution: {integrity: sha512-PYxZDA+6QtvRvm//++aGdmKG/cI07jNwbROz0Ql+VzFV1+Z0Dy55NI4zZ7RHc9KKpBePNFwoErqIuqQv/cjiTA==}
|
||||
engines: {node: '>= 0.12.0'}
|
||||
|
||||
v-scale-screen@2.3.0:
|
||||
resolution: {integrity: sha512-SoYxvdZ9qi4Ne8BDDflIRU0IfX/qgmtZ0pPaZ4rGB+/Wr0GBYQbjRnoHF+uq1JqqUsBtiyjUnAkrG6xYTMXFpA==}
|
||||
peerDependencies:
|
||||
vue: ^3.2.37
|
||||
|
||||
validate-npm-package-license@3.0.4:
|
||||
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
|
||||
|
||||
@ -10931,6 +10939,10 @@ snapshots:
|
||||
mz: 2.7.0
|
||||
unescape: 1.0.1
|
||||
|
||||
v-scale-screen@2.3.0(vue@3.5.26(typescript@5.9.3)):
|
||||
dependencies:
|
||||
vue: 3.5.26(typescript@5.9.3)
|
||||
|
||||
validate-npm-package-license@3.0.4:
|
||||
dependencies:
|
||||
spdx-correct: 3.2.0
|
||||
|
||||