hookehuyr

feat: 添加新图片资源并初始化项目基础结构

添加了多个新图片资源,包括用户头像、课程封面和活动图片。同时,初始化了项目的基础结构,包括配置文件的设置(如postcss.config.js、tailwind.config.js、.gitignore等)、主入口文件(main.js)的创建,以及基础组件和页面的实现(如App.vue、HelloWorld.vue、FrostedGlass.vue等)。这些更改为后续开发提供了必要的资源支持和基础框架。
Showing 81 changed files with 4183 additions and 0 deletions
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.history
node_modules
.vscode
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
This diff could not be displayed because it is too large.
{
"name": "vue-vite",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@vant/use": "^1.6.0",
"vant": "^4.9.18",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.1.2",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"vite": "^6.2.0"
}
}
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
\ No newline at end of file
<!--
* @Date: 2025-03-20 19:53:12
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-03-20 20:06:10
* @FilePath: /vue-vite/src/App.vue
* @Description: 文件描述
-->
<script setup>
import { RouterView } from "vue-router";
</script>
<template>
<div>
<router-view>
<template v-slot="{ Component }">
<Suspense>
<template #default>
<div>
<component :is="Component" />
</div>
</template>
<template #fallback>
<div
class="flex items-center justify-center h-screen bg-gradient-to-br from-green-50 via-teal-50 to-blue-50"
>
<div class="bg-white/20 backdrop-blur-md rounded-xl p-6 shadow-lg">
<div
class="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500 mx-auto"
></div>
<p class="mt-4 text-gray-700">加载中...</p>
</div>
</div>
</template>
</Suspense>
</template>
</router-view>
</div>
</template>
<style>
#app {
width: 100%;
min-height: 100vh;
}
</style>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
\ No newline at end of file
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>
import React from 'react';
import BottomNav from './BottomNav';
import GradientHeader from '../ui/GradientHeader';
/**
* AppLayout component provides consistent layout across the app
*
* @param {Object} props - Component props
* @param {ReactNode} props.children - Child elements
* @param {string} props.title - Page title
* @param {boolean} props.showBackButton - Whether to display back button
* @param {Function} props.onBack - Back button click handler
* @param {ReactNode} props.rightContent - Content to display on the right side of header
* @returns {JSX.Element} AppLayout component
*/
const AppLayout = ({ children, title, showBackButton, onBack, rightContent }) => {
const handleBack = () => {
if (onBack) {
onBack();
} else {
window.history.back();
}
};
return (
<div className="bg-gradient-to-br from-green-50 via-teal-50 to-blue-50 min-h-screen pb-16">
<GradientHeader
title={title}
showBackButton={showBackButton}
onBack={handleBack}
rightContent={rightContent}
/>
<main className="pb-16">
{children}
</main>
<BottomNav />
</div>
);
};
export default AppLayout;
\ No newline at end of file
<template>
<div class="bg-gradient-to-br from-green-50 via-teal-50 to-blue-50 min-h-screen pb-16">
<GradientHeader
:title="title"
:showBackButton="showBackButton"
:onBack="handleBack"
:rightContent="rightContent"
/>
<main class="pb-16">
<slot></slot>
</main>
<BottomNav />
</div>
</template>
<script setup>
import { defineProps } from 'vue'
import BottomNav from './BottomNav.vue'
import GradientHeader from '../ui/GradientHeader.vue'
const props = defineProps({
title: {
type: String,
required: true
},
showBackButton: {
type: Boolean,
default: false
},
onBack: {
type: Function,
default: null
},
rightContent: {
type: Object,
default: null
}
})
const handleBack = () => {
if (props.onBack) {
props.onBack()
} else {
window.history.back()
}
}
</script>
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
/**
* BottomNav component for app navigation
*
* @returns {JSX.Element} BottomNav component
*/
const BottomNav = () => {
const location = useLocation();
const navItems = [
{
name: '首页',
path: '/',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mb-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
)
},
{
name: '课程',
path: '/courses',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mb-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
)
},
{
name: '空间',
path: '/community',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mb-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
)
},
{
name: '我的',
path: '/profile',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mb-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
)
}
];
return (
<nav className="fixed bottom-0 left-0 right-0 bg-white/70 backdrop-blur-lg border-t border-gray-100 z-50">
<div className="flex justify-around items-center h-16">
{navItems.map((item) => {
const isActive = location.pathname === item.path ||
(item.path !== '/' && location.pathname.startsWith(item.path));
return (
<Link
key={item.name}
to={item.path}
className={`flex flex-col items-center justify-center w-1/4 h-full ${
isActive ? 'text-green-500' : 'text-gray-500'
}`}
>
{React.cloneElement(item.icon, {
className: `${item.icon.props.className} ${isActive ? 'text-green-500' : 'text-gray-500'}`
})}
<span className="text-xs">{item.name}</span>
</Link>
);
})}
</div>
</nav>
);
};
export default BottomNav;
\ No newline at end of file
<template>
<nav class="fixed bottom-0 left-0 right-0 bg-white/70 backdrop-blur-lg border-t border-gray-100 z-50">
<div class="flex justify-around items-center h-16">
<router-link
v-for="item in navItems"
:key="item.name"
:to="item.path"
class="flex flex-col items-center justify-center w-1/4 h-full"
:class="{
'text-green-500': isActive(item.path),
'text-gray-500': !isActive(item.path)
}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 mb-1"
:class="{
'text-green-500': isActive(item.path),
'text-gray-500': !isActive(item.path)
}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
v-html="item.icon"
/>
<span class="text-xs">{{ item.name }}</span>
</router-link>
</div>
</nav>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const navItems = [
{
name: '首页',
path: '/',
icon: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />'
},
{
name: '课程',
path: '/courses',
icon: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />'
},
{
name: '空间',
path: '/community',
icon: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />'
},
{
name: '我的',
path: '/profile',
icon: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />'
}
]
const isActive = (path) => {
return route.path === path || (path !== '/' && route.path.startsWith(path))
}
</script>
import React from 'react';
import { Link } from 'react-router-dom';
import FrostedGlass from './FrostedGlass';
/**
* ActivityCard component displays an activity item in the activities list
*
* @param {Object} props - Component props
* @param {Object} props.activity - Activity data
* @returns {JSX.Element} ActivityCard component
*/
const ActivityCard = ({ activity }) => {
// Function to get the appropriate status class
const getStatusClass = (status) => {
switch (status) {
case '活动中':
return 'bg-blue-100 text-blue-600';
case '进行中':
return 'bg-green-100 text-green-600';
case '即将开始':
return 'bg-orange-100 text-orange-600';
case '已结束':
return 'bg-gray-100 text-gray-600';
default:
return 'bg-gray-100 text-gray-600';
}
};
return (
<Link to={`/activities/${activity.id}`}>
<FrostedGlass className="flex overflow-hidden rounded-xl shadow-sm">
{/* Activity Image */}
<div className="w-1/3 h-28 relative">
<img
src={activity.imageUrl}
alt={activity.title}
className="w-full h-full object-cover"
/>
{activity.isHot && (
<div className="absolute top-0 left-0 bg-red-500 text-white text-xs px-2 py-0.5">
热门
</div>
)}
</div>
{/* Activity Info */}
<div className="flex-1 p-3 flex flex-col justify-between">
<div>
<h3 className="font-medium text-base mb-1 line-clamp-1">{activity.title}</h3>
{/* Status Tags */}
<div className="flex items-center space-x-2 mb-1">
<span className={`px-2 py-0.5 rounded-full text-xs ${getStatusClass(activity.status)}`}>
{activity.status}
</span>
{activity.isFree && (
<span className="px-2 py-0.5 rounded-full text-xs bg-green-100 text-green-600">
免费
</span>
)}
</div>
</div>
{/* Location and Time */}
<div className="text-xs text-gray-500 space-y-1">
<div className="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>{activity.location}</span>
</div>
<div className="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>{activity.period}</span>
</div>
</div>
{/* Bottom Info Section */}
<div className="mt-1 flex items-center justify-between">
{activity.price ? (
<div className="flex items-baseline">
<span className="text-red-500 font-medium">¥{activity.price}</span>
{activity.originalPrice && (
<span className="text-xs text-gray-400 ml-1 line-through">¥{activity.originalPrice}</span>
)}
</div>
) : (
<div></div> // Empty div for spacing when no price
)}
<div className="flex items-center text-xs text-gray-500">
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<span>{activity.participantsCount || '15'}/{activity.maxParticipants || '30'}</span>
</div>
</div>
</div>
</FrostedGlass>
</Link>
);
};
export default ActivityCard;
\ No newline at end of file
<template>
<router-link :to="`/activities/${activity.id}`">
<FrostedGlass class="flex overflow-hidden rounded-xl shadow-sm">
<!-- Activity Image -->
<div class="w-1/3 h-28 relative">
<img
:src="activity.imageUrl"
:alt="activity.title"
class="w-full h-full object-cover"
/>
<div v-if="activity.isHot" class="absolute top-0 left-0 bg-red-500 text-white text-xs px-2 py-0.5">
热门
</div>
</div>
<!-- Activity Info -->
<div class="flex-1 p-3 flex flex-col justify-between">
<div>
<h3 class="font-medium text-base mb-1 line-clamp-1">{{ activity.title }}</h3>
<!-- Status Tags -->
<div class="flex items-center space-x-2 mb-1">
<span :class="['px-2 py-0.5 rounded-full text-xs', getStatusClass(activity.status)]">
{{ activity.status }}
</span>
<span v-if="activity.isFree" class="px-2 py-0.5 rounded-full text-xs bg-green-100 text-green-600">
免费
</span>
</div>
</div>
<!-- Location and Time -->
<div class="text-xs text-gray-500 space-y-1">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>{{ activity.location }}</span>
</div>
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>{{ activity.period }}</span>
</div>
</div>
<!-- Bottom Info Section -->
<div class="mt-1 flex items-center justify-between">
<div v-if="activity.price" class="flex items-baseline">
<span class="text-red-500 font-medium">¥{{ activity.price }}</span>
<span v-if="activity.originalPrice" class="text-xs text-gray-400 ml-1 line-through">¥{{ activity.originalPrice }}</span>
</div>
<div v-else></div>
<div class="flex items-center text-xs text-gray-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<span>{{ activity.participantsCount || '15' }}/{{ activity.maxParticipants || '30' }}</span>
</div>
</div>
</div>
</FrostedGlass>
</router-link>
</template>
<script setup>
import { defineProps } from 'vue'
import FrostedGlass from './FrostedGlass.vue'
defineProps({
activity: {
type: Object,
required: true
}
})
const getStatusClass = (status) => {
switch (status) {
case '活动中':
return 'bg-blue-100 text-blue-600'
case '进行中':
return 'bg-green-100 text-green-600'
case '即将开始':
return 'bg-orange-100 text-orange-600'
case '已结束':
return 'bg-gray-100 text-gray-600'
default:
return 'bg-gray-100 text-gray-600'
}
}
</script>
import React from 'react';
import { Link } from 'react-router-dom';
/**
* CourseCard component displays a course item in the course list
*
* @param {Object} props - Component props
* @param {Object} props.course - Course data
* @returns {JSX.Element} CourseCard component
*/
const CourseCard = ({ course }) => {
return (
<Link to={`/courses/${course.id}`} className="flex bg-white rounded-lg overflow-hidden shadow-sm">
<div className="w-1/3 h-28">
<img
src={course.imageUrl}
alt={course.title}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 p-3 flex flex-col justify-between">
<div>
<h3 className="font-medium text-sm mb-1 line-clamp-2">{course.title}</h3>
<div className="text-gray-500 text-xs">{course.subtitle}</div>
</div>
<div className="flex justify-between items-end mt-1">
<div className="text-orange-500 font-semibold">¥{course.price}</div>
<div className="text-gray-400 text-xs">
{course.subscribers}人订阅
</div>
</div>
<div className="text-gray-400 text-xs">
已更新{course.updatedLessons}期 | {course.subscribers}人订阅
</div>
</div>
</Link>
);
};
export default CourseCard;
\ No newline at end of file
<template>
<router-link :to="`/courses/${course.id}`" class="flex bg-white rounded-lg overflow-hidden shadow-sm">
<div class="w-1/3 h-28">
<img
:src="course.imageUrl"
:alt="course.title"
class="w-full h-full object-cover"
/>
</div>
<div class="flex-1 p-3 flex flex-col justify-between">
<div>
<h3 class="font-medium text-sm mb-1 line-clamp-2">{{ course.title }}</h3>
<div class="text-gray-500 text-xs">{{ course.subtitle }}</div>
</div>
<div class="flex justify-between items-end mt-1">
<div class="text-orange-500 font-semibold">¥{{ course.price }}</div>
<div class="text-gray-400 text-xs">
{{ course.subscribers }}人订阅
</div>
</div>
<div class="text-gray-400 text-xs">
已更新{{ course.updatedLessons }}期 | {{ course.subscribers }}人订阅
</div>
</div>
</router-link>
</template>
<script setup>
import { defineProps } from 'vue'
defineProps({
course: {
type: Object,
required: true
}
})
</script>
import React from 'react';
/**
* FrostedGlass component creates a container with a frosted glass effect
* using backdrop-filter blur and a semi-transparent white background.
*
* @param {Object} props - Component props
* @param {ReactNode} props.children - Child elements
* @param {string} props.className - Additional CSS classes
* @returns {JSX.Element} FrostedGlass component
*/
const FrostedGlass = ({ children, className = '' }) => {
return (
<div
className={`bg-white/20 backdrop-blur-md rounded-xl border border-white/30
shadow-lg ${className}`}
>
{children}
</div>
);
};
export default FrostedGlass;
\ No newline at end of file
<template>
<div
:class="[
'bg-white/20 backdrop-blur-md rounded-xl border border-white/30 shadow-lg',
className
]"
>
<slot></slot>
</div>
</template>
<script setup>
import { defineProps } from 'vue'
defineProps({
className: {
type: String,
default: ''
}
})
</script>
import React from 'react';
/**
* GradientHeader component for page headers with gradient background
* and navigation elements.
*
* @param {Object} props - Component props
* @param {string} props.title - Header title
* @param {boolean} props.showBackButton - Whether to show back button
* @param {Function} props.onBack - Back button click handler
* @param {ReactNode} props.rightContent - Content to display on the right side
* @returns {JSX.Element} GradientHeader component
*/
const GradientHeader = ({ title, showBackButton = false, onBack, rightContent }) => {
return (
<header className="bg-gradient-to-r from-green-50 to-blue-50 p-4 relative">
<div className="flex items-center justify-between">
{showBackButton && (
<button
onClick={onBack}
className="p-2 rounded-full bg-white/30 backdrop-blur-sm"
aria-label="返回"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
)}
<h1 className={`text-xl font-medium text-center ${showBackButton ? 'flex-1' : ''}`}>
{title}
</h1>
{rightContent && <div>{rightContent}</div>}
</div>
</header>
);
};
export default GradientHeader;
\ No newline at end of file
<template>
<header class="bg-gradient-to-r from-green-50 to-blue-50 p-4 relative">
<div class="flex items-center justify-between">
<button
v-if="showBackButton"
@click="onBack"
class="p-2 rounded-full bg-white/30 backdrop-blur-sm"
aria-label="返回"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<h1 :class="['text-xl font-medium text-center', showBackButton ? 'flex-1' : '']">
{{ title }}
</h1>
<div v-if="rightContent" v-html="rightContent.content"></div>
</div>
</header>
</template>
<script setup>
import { defineProps } from 'vue'
defineProps({
title: {
type: String,
required: true
},
showBackButton: {
type: Boolean,
default: false
},
onBack: {
type: Function,
default: () => {}
},
rightContent: {
type: Object,
default: null
}
})
</script>
import React from 'react';
import { Link } from 'react-router-dom';
/**
* LiveStreamCard component displays a live stream in the courses page
*
* @param {Object} props - Component props
* @param {Object} props.stream - Stream data
* @returns {JSX.Element} LiveStreamCard component
*/
const LiveStreamCard = ({ stream }) => {
return (
<div className="relative">
{/* Live indicator */}
<div className="absolute top-2 left-2 bg-red-500/90 text-white text-xs px-2 py-1 rounded flex items-center z-10">
<div className="w-2 h-2 bg-white rounded-full mr-1 animate-pulse"></div>
直播中
</div>
<Link to={`/courses/${stream.id}`} className="block rounded-lg overflow-hidden shadow-sm relative">
<img
src={stream.imageUrl}
alt={stream.title}
className="w-full h-28 object-cover"
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/60"></div>
{/* Stream info */}
<div className="absolute bottom-2 left-2 right-2">
<h3 className="text-white text-sm font-medium">{stream.title}{stream.subtitle}</h3>
<div className="flex items-center mt-1">
<div className="flex -space-x-2">
<div className="w-5 h-5 rounded-full bg-gray-300 border border-white"></div>
<div className="w-5 h-5 rounded-full bg-gray-400 border border-white"></div>
<div className="w-5 h-5 rounded-full bg-gray-500 border border-white"></div>
</div>
<span className="text-white text-xs ml-1">{stream.viewers}人在看</span>
<button className="ml-auto bg-green-500 text-white text-xs px-2 py-1 rounded">
立即观看
</button>
</div>
</div>
</Link>
</div>
);
};
export default LiveStreamCard;
\ No newline at end of file
<!--
* @Date: 2025-03-20 15:33:07
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-03-20 15:33:08
* @FilePath: /react_template_1/src/components/ui/LiveStreamCard.vue
* @Description: 文件描述
-->
<template>
<div class="relative">
<!-- Live indicator -->
<div class="absolute top-2 left-2 bg-red-500/90 text-white text-xs px-2 py-1 rounded flex items-center z-10">
<div class="w-2 h-2 bg-white rounded-full mr-1 animate-pulse"></div>
直播中
</div>
<router-link :to="`/courses/${stream.id}`" class="block rounded-lg overflow-hidden shadow-sm relative">
<img
:src="stream.imageUrl"
:alt="stream.title"
class="w-full h-28 object-cover"
/>
<!-- Gradient overlay -->
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-black/60"></div>
<!-- Stream info -->
<div class="absolute bottom-2 left-2 right-2">
<h3 class="text-white text-sm font-medium">「{{ stream.title }}」{{ stream.subtitle }}</h3>
<div class="flex items-center mt-1">
<div class="flex -space-x-2">
<div class="w-5 h-5 rounded-full bg-gray-300 border border-white"></div>
<div class="w-5 h-5 rounded-full bg-gray-400 border border-white"></div>
<div class="w-5 h-5 rounded-full bg-gray-500 border border-white"></div>
</div>
<span class="text-white text-xs ml-1">{{ stream.viewers }}人在看</span>
<button class="ml-auto bg-green-500 text-white text-xs px-2 py-1 rounded">
立即观看
</button>
</div>
</div>
</router-link>
</div>
</template>
<script setup>
import { defineProps } from 'vue'
defineProps({
stream: {
type: Object,
required: true
}
})
</script>
import React from 'react';
import FrostedGlass from './FrostedGlass';
/**
* SearchBar component with frosted glass effect
*
* @param {Object} props - Component props
* @param {string} props.placeholder - Placeholder text
* @param {Function} props.onSearch - Search callback function
* @returns {JSX.Element} SearchBar component
*/
const SearchBar = ({ placeholder = '搜索', onSearch }) => {
return (
<FrostedGlass className="px-4 py-2 mx-4 my-3 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-gray-400 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder={placeholder}
className="bg-transparent outline-none flex-1 text-gray-700 placeholder-gray-400"
onChange={(e) => onSearch && onSearch(e.target.value)}
/>
</FrostedGlass>
);
};
export default SearchBar;
\ No newline at end of file
<template>
<FrostedGlass class="px-4 py-2 mx-4 my-3 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
:placeholder="placeholder"
class="bg-transparent outline-none flex-1 text-gray-700 placeholder-gray-400"
@input="handleSearch"
/>
</FrostedGlass>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
import FrostedGlass from './FrostedGlass.vue'
const props = defineProps({
placeholder: {
type: String,
default: '搜索'
}
})
const emit = defineEmits(['search'])
const handleSearch = (e) => {
emit('search', e.target.value)
}
</script>
import React from 'react';
import PropTypes from 'prop-types';
/**
* SummerCampCard component - displays summer camp information with image background
* @param {Object} props - Component props
* @returns {JSX.Element} - Rendered component
*/
const SummerCampCard = ({
title = "大国少年-世界正东方",
subtitle = "亲子夏令营",
badge = "亲子夏令营",
price = "¥1280",
discount = "限时优惠",
episodes = 16,
subscribers = 1140
}) => {
return (
<div className="relative overflow-hidden rounded-b-3xl shadow-lg">
{/* Background image with overlay */}
<div
className="absolute inset-0 z-0 bg-cover bg-center"
style={{
backgroundImage: `url('/assets/images/summer-camp.jpg')`,
filter: 'brightness(0.4)'
}}
></div>
{/* Gradient overlay */}
<div className="absolute inset-0 z-1 bg-gradient-to-b from-red-500/70 to-red-600/90"></div>
{/* Content */}
<div className="relative z-10 p-4">
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-3 mb-3 inline-block">
<div className="text-white font-semibold">{badge}</div>
</div>
<h1 className="text-2xl text-white font-bold mb-1">{title}</h1>
<h2 className="text-lg text-white/90">{subtitle}</h2>
<div className="mt-4 flex justify-between items-center">
<div className="text-orange-300 font-bold text-2xl">{price}</div>
<div className="bg-orange-500/30 text-orange-100 text-xs px-3 py-1 rounded-full">{discount}</div>
</div>
<div className="flex justify-between text-xs text-white/80 mt-3">
<div>已更新{episodes}</div>
<div>{subscribers}人订阅</div>
</div>
</div>
</div>
);
};
SummerCampCard.propTypes = {
title: PropTypes.string,
subtitle: PropTypes.string,
badge: PropTypes.string,
price: PropTypes.string,
discount: PropTypes.string,
episodes: PropTypes.number,
subscribers: PropTypes.number
};
export default SummerCampCard;
\ No newline at end of file
<template>
<div class="relative overflow-hidden rounded-b-3xl shadow-lg">
<!-- Background image with overlay -->
<div
class="absolute inset-0 z-0 bg-cover bg-center"
:style="{
backgroundImage: `url('https://cdn.ipadbiz.cn/mlaj/images/summer-camp.jpg')`,
filter: 'brightness(0.4)'
}"
></div>
<!-- Gradient overlay -->
<div class="absolute inset-0 z-1 bg-gradient-to-b from-red-500/70 to-red-600/90"></div>
<!-- Content -->
<div class="relative z-10 p-4">
<div class="bg-white/10 backdrop-blur-sm rounded-lg p-3 mb-3 inline-block">
<div class="text-white font-semibold">{{ badge }}</div>
</div>
<h1 class="text-2xl text-white font-bold mb-1">{{ title }}</h1>
<h2 class="text-lg text-white/90">{{ subtitle }}</h2>
<div class="mt-4 flex justify-between items-center">
<div class="text-orange-300 font-bold text-2xl">{{ price }}</div>
<div class="bg-orange-500/30 text-orange-100 text-xs px-3 py-1 rounded-full">{{ discount }}</div>
</div>
<div class="flex justify-between text-xs text-white/80 mt-3">
<div>已更新{{ episodes }}期</div>
<div>{{ subscribers }}人订阅</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps } from 'vue'
defineProps({
title: {
type: String,
default: '大国少年-世界正东方'
},
subtitle: {
type: String,
default: '亲子夏令营'
},
badge: {
type: String,
default: '亲子夏令营'
},
price: {
type: String,
default: '¥1280'
},
discount: {
type: String,
default: '限时优惠'
},
episodes: {
type: Number,
default: 16
},
subscribers: {
type: Number,
default: 1140
}
})
</script>
<!-- src/layouts/AppLayout.vue -->
<template>
<div class="app-layout">
<!-- Header -->
<header class="app-header">
<div v-if="showBack" class="header-back" @click="goBack">
<van-icon name="arrow-left" size="20" />
</div>
<h1 class="header-title">{{ title }}</h1>
<div class="header-right">
<slot name="header-right"></slot>
</div>
</header>
<!-- Main Content -->
<main class="app-content" :class="{ 'has-bottom-nav': showBottomNav }">
<slot></slot>
</main>
<!-- Bottom Navigation -->
<van-tabbar v-if="showBottomNav" route safe-area-inset-bottom>
<van-tabbar-item to="/home" icon="home-o">首页</van-tabbar-item>
<van-tabbar-item to="/courses" icon="orders-o">课程</van-tabbar-item>
<van-tabbar-item to="/activities" icon="friends-o">活动</van-tabbar-item>
<van-tabbar-item to="/community" icon="chat-o">社区</van-tabbar-item>
<van-tabbar-item to="/profile" icon="user-o">我的</van-tabbar-item>
</van-tabbar>
</div>
</template>
<script>
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
export default {
name: 'AppLayout',
props: {
title: {
type: String,
default: '亲子学院'
},
showBack: {
type: Boolean,
default: false
},
showBottomNav: {
type: Boolean,
default: true
}
},
setup(props) {
const router = useRouter()
const route = useRoute()
const goBack = () => {
if (window.history.length > 1) {
router.back()
} else {
router.push('/')
}
}
return {
goBack
}
}
}
</script>
<style scoped>
.app-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: var(--background-color);
}
.app-header {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
height: 46px;
padding: 0 16px;
background-color: var(--white);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
.header-back {
position: absolute;
left: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.header-title {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.header-right {
position: absolute;
right: 16px;
display: flex;
align-items: center;
}
.app-content {
flex: 1;
overflow-y: auto;
padding-bottom: 20px;
-webkit-overflow-scrolling: touch;
}
.app-content.has-bottom-nav {
padding-bottom: 50px;
}
</style>
\ No newline at end of file
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import router from './router'
// 导入Vant组件和样式
import { Button, NavBar, Tabbar, TabbarItem } from 'vant'
import 'vant/lib/index.css'
const app = createApp(App)
app.use(router)
// 注册Vant组件
app.use(Button)
app.use(NavBar)
app.use(Tabbar)
app.use(TabbarItem)
app.mount('#app')
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'home',
component: () => import('../views/Home.vue')
},
{
path: '/about',
name: 'about',
component: () => import('../views/About.vue')
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom fonts */
/* @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&display=swap'); */
/* Base styles */
:root {
--primary-color: #4caf50;
--secondary-color: #2196f3;
--background-start: #f0f9ef;
--background-end: #e6f4ff;
--text-color: #333333;
--text-light: #6e6e6e;
}
body {
font-family: 'Noto Sans SC', sans-serif;
@apply text-gray-800;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
::-webkit-scrollbar-track {
background-color: transparent;
}
::-webkit-scrollbar-thumb {
border-radius: 25px;
transition: all 0.3s;
background-color: rgba(106, 115, 125, 0.2);
&:hover {
background-color: rgba(106, 115, 125, 0.27);
}
}
::-webkit-scrollbar-corner {
display: none;
}
/* Custom frosted glass components */
.frosted-glass {
@apply bg-white/20 backdrop-blur-md border border-white/30 shadow-lg rounded-xl;
}
.frosted-card {
@apply frosted-glass p-4;
}
.gradient-bg {
@apply bg-gradient-to-br from-green-50 via-teal-50 to-blue-50;
}
/* Custom animations */
.fade-in {
animation: fadeIn 0.5s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Button styles */
.btn-primary {
@apply bg-gradient-to-r from-green-500 to-emerald-500 text-white py-2 px-4 rounded-full
shadow-md hover:shadow-lg transition-all duration-300 font-medium;
}
.btn-secondary {
@apply bg-white/70 backdrop-blur-sm text-gray-700 py-2 px-4 rounded-full
shadow-sm hover:shadow transition-all duration-300 border border-gray-100;
}
.btn-outline {
@apply border border-green-500 text-green-500 bg-transparent py-2 px-4 rounded-full
hover:bg-green-50 transition-all duration-300;
}
/* Badge styles */
.badge {
@apply px-2 py-1 text-xs rounded-full font-medium;
}
.badge-success {
@apply bg-green-100 text-green-700;
}
.badge-info {
@apply bg-blue-100 text-blue-700;
}
.badge-warning {
@apply bg-orange-100 text-orange-700;
}
/* Custom utilities */
.text-gradient {
@apply bg-clip-text text-transparent bg-gradient-to-r from-green-600 to-blue-600;
}
.card-shadow {
box-shadow: 0 8px 20px -8px rgba(0, 0, 0, 0.1);
}
/* Price tag */
.price-tag {
@apply text-orange-500 font-semibold;
}
*, ::before, ::after {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
}
::backdrop {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
}/*
! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com
*//*
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
*/
*,
::before,
::after {
box-sizing: border-box; /* 1 */
border-width: 0; /* 2 */
border-style: solid; /* 2 */
border-color: #e5e7eb; /* 2 */
}
::before,
::after {
--tw-content: '';
}
/*
1. Use a consistent sensible line-height in all browsers.
2. Prevent adjustments of font size after orientation changes in iOS.
3. Use a more readable tab size.
4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings by default.
6. Use the user's configured `sans` font-variation-settings by default.
7. Disable tap highlights on iOS
*/
html,
:host {
line-height: 1.5; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
-moz-tab-size: 4; /* 3 */
-o-tab-size: 4;
tab-size: 4; /* 3 */
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; /* 4 */
font-feature-settings: normal; /* 5 */
font-variation-settings: normal; /* 6 */
-webkit-tap-highlight-color: transparent; /* 7 */
}
/*
1. Remove the margin in all browsers.
2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
*/
body {
margin: 0; /* 1 */
line-height: inherit; /* 2 */
}
/*
1. Add the correct height in Firefox.
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
3. Ensure horizontal rules are visible by default.
*/
hr {
height: 0; /* 1 */
color: inherit; /* 2 */
border-top-width: 1px; /* 3 */
}
/*
Add the correct text decoration in Chrome, Edge, and Safari.
*/
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
/*
Remove the default font size and weight for headings.
*/
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
/*
Reset links to optimize for opt-in styling instead of opt-out.
*/
a {
color: inherit;
text-decoration: inherit;
}
/*
Add the correct font weight in Edge and Safari.
*/
b,
strong {
font-weight: bolder;
}
/*
1. Use the user's configured `mono` font-family by default.
2. Use the user's configured `mono` font-feature-settings by default.
3. Use the user's configured `mono` font-variation-settings by default.
4. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp,
pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; /* 1 */
font-feature-settings: normal; /* 2 */
font-variation-settings: normal; /* 3 */
font-size: 1em; /* 4 */
}
/*
Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/*
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/*
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
3. Remove gaps between table borders by default.
*/
table {
text-indent: 0; /* 1 */
border-color: inherit; /* 2 */
border-collapse: collapse; /* 3 */
}
/*
1. Change the font styles in all browsers.
2. Remove the margin in Firefox and Safari.
3. Remove default padding in all browsers.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-feature-settings: inherit; /* 1 */
font-variation-settings: inherit; /* 1 */
font-size: 100%; /* 1 */
font-weight: inherit; /* 1 */
line-height: inherit; /* 1 */
letter-spacing: inherit; /* 1 */
color: inherit; /* 1 */
margin: 0; /* 2 */
padding: 0; /* 3 */
}
/*
Remove the inheritance of text transform in Edge and Firefox.
*/
button,
select {
text-transform: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Remove default button styles.
*/
button,
input:where([type='button']),
input:where([type='reset']),
input:where([type='submit']) {
-webkit-appearance: button; /* 1 */
background-color: transparent; /* 2 */
background-image: none; /* 2 */
}
/*
Use the modern Firefox focus style for all focusable elements.
*/
:-moz-focusring {
outline: auto;
}
/*
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
*/
:-moz-ui-invalid {
box-shadow: none;
}
/*
Add the correct vertical alignment in Chrome and Firefox.
*/
progress {
vertical-align: baseline;
}
/*
Correct the cursor style of increment and decrement buttons in Safari.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/*
1. Correct the odd appearance in Chrome and Safari.
2. Correct the outline style in Safari.
*/
[type='search'] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/*
Remove the inner padding in Chrome and Safari on macOS.
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/*
Add the correct display in Chrome and Safari.
*/
summary {
display: list-item;
}
/*
Removes the default spacing and border for appropriate elements.
*/
blockquote,
dl,
dd,
h1,
h2,
h3,
h4,
h5,
h6,
hr,
figure,
p,
pre {
margin: 0;
}
fieldset {
margin: 0;
padding: 0;
}
legend {
padding: 0;
}
ol,
ul,
menu {
list-style: none;
margin: 0;
padding: 0;
}
/*
Reset default styling for dialogs.
*/
dialog {
padding: 0;
}
/*
Prevent resizing textareas horizontally by default.
*/
textarea {
resize: vertical;
}
/*
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
2. Set the default placeholder color to the user's configured gray 400 color.
*/
input::-moz-placeholder, textarea::-moz-placeholder {
opacity: 1; /* 1 */
color: #9ca3af; /* 2 */
}
input::placeholder,
textarea::placeholder {
opacity: 1; /* 1 */
color: #9ca3af; /* 2 */
}
/*
Set the default cursor for buttons.
*/
button,
[role="button"] {
cursor: pointer;
}
/*
Make sure disabled buttons don't get the pointer cursor.
*/
:disabled {
cursor: default;
}
/*
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
This can trigger a poorly considered lint error in some tools but is included by design.
*/
img,
svg,
video,
canvas,
audio,
iframe,
embed,
object {
display: block; /* 1 */
vertical-align: middle; /* 2 */
}
/*
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
*/
img,
video {
max-width: 100%;
height: auto;
}
/* Make elements with the HTML hidden attribute stay hidden by default */
[hidden]:where(:not([hidden="until-found"])) {
display: none;
}
.container {
width: 100%;
}
@media (min-width: 640px) {
.container {
max-width: 640px;
}
}
@media (min-width: 768px) {
.container {
max-width: 768px;
}
}
@media (min-width: 1024px) {
.container {
max-width: 1024px;
}
}
@media (min-width: 1280px) {
.container {
max-width: 1280px;
}
}
@media (min-width: 1536px) {
.container {
max-width: 1536px;
}
}
.fixed {
position: fixed;
}
.absolute {
position: absolute;
}
.relative {
position: relative;
}
.sticky {
position: sticky;
}
.inset-0 {
inset: 0px;
}
.bottom-0 {
bottom: 0px;
}
.bottom-16 {
bottom: 4rem;
}
.bottom-2 {
bottom: 0.5rem;
}
.left-0 {
left: 0px;
}
.left-2 {
left: 0.5rem;
}
.right-0 {
right: 0px;
}
.right-2 {
right: 0.5rem;
}
.top-0 {
top: 0px;
}
.top-2 {
top: 0.5rem;
}
.z-0 {
z-index: 0;
}
.z-10 {
z-index: 10;
}
.z-50 {
z-index: 50;
}
.mx-1 {
margin-left: 0.25rem;
margin-right: 0.25rem;
}
.mx-2 {
margin-left: 0.5rem;
margin-right: 0.5rem;
}
.mx-4 {
margin-left: 1rem;
margin-right: 1rem;
}
.mx-auto {
margin-left: auto;
margin-right: auto;
}
.my-3 {
margin-top: 0.75rem;
margin-bottom: 0.75rem;
}
.mb-1 {
margin-bottom: 0.25rem;
}
.mb-2 {
margin-bottom: 0.5rem;
}
.mb-3 {
margin-bottom: 0.75rem;
}
.mb-4 {
margin-bottom: 1rem;
}
.mb-5 {
margin-bottom: 1.25rem;
}
.mb-6 {
margin-bottom: 1.5rem;
}
.mb-7 {
margin-bottom: 1.75rem;
}
.ml-1 {
margin-left: 0.25rem;
}
.ml-2 {
margin-left: 0.5rem;
}
.ml-auto {
margin-left: auto;
}
.mr-1 {
margin-right: 0.25rem;
}
.mr-2 {
margin-right: 0.5rem;
}
.mr-3 {
margin-right: 0.75rem;
}
.mr-4 {
margin-right: 1rem;
}
.mt-1 {
margin-top: 0.25rem;
}
.mt-2 {
margin-top: 0.5rem;
}
.mt-3 {
margin-top: 0.75rem;
}
.mt-4 {
margin-top: 1rem;
}
.mt-5 {
margin-top: 1.25rem;
}
.mt-6 {
margin-top: 1.5rem;
}
.mt-8 {
margin-top: 2rem;
}
.mt-auto {
margin-top: auto;
}
.line-clamp-1 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.line-clamp-2 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.block {
display: block;
}
.inline-block {
display: inline-block;
}
.flex {
display: flex;
}
.grid {
display: grid;
}
.h-1\.5 {
height: 0.375rem;
}
.h-10 {
height: 2.5rem;
}
.h-12 {
height: 3rem;
}
.h-14 {
height: 3.5rem;
}
.h-16 {
height: 4rem;
}
.h-2 {
height: 0.5rem;
}
.h-20 {
height: 5rem;
}
.h-24 {
height: 6rem;
}
.h-28 {
height: 7rem;
}
.h-3 {
height: 0.75rem;
}
.h-3\.5 {
height: 0.875rem;
}
.h-32 {
height: 8rem;
}
.h-4 {
height: 1rem;
}
.h-40 {
height: 10rem;
}
.h-48 {
height: 12rem;
}
.h-5 {
height: 1.25rem;
}
.h-56 {
height: 14rem;
}
.h-6 {
height: 1.5rem;
}
.h-64 {
height: 16rem;
}
.h-auto {
height: auto;
}
.h-full {
height: 100%;
}
.h-screen {
height: 100vh;
}
.max-h-60 {
max-height: 15rem;
}
.min-h-screen {
min-height: 100vh;
}
.w-1\/3 {
width: 33.333333%;
}
.w-1\/4 {
width: 25%;
}
.w-10 {
width: 2.5rem;
}
.w-12 {
width: 3rem;
}
.w-14 {
width: 3.5rem;
}
.w-16 {
width: 4rem;
}
.w-2 {
width: 0.5rem;
}
.w-20 {
width: 5rem;
}
.w-24 {
width: 6rem;
}
.w-3 {
width: 0.75rem;
}
.w-3\.5 {
width: 0.875rem;
}
.w-4 {
width: 1rem;
}
.w-5 {
width: 1.25rem;
}
.w-6 {
width: 1.5rem;
}
.w-fit {
width: -moz-fit-content;
width: fit-content;
}
.w-full {
width: 100%;
}
.min-w-\[18px\] {
min-width: 18px;
}
.flex-1 {
flex: 1 1 0%;
}
.flex-shrink-0 {
flex-shrink: 0;
}
@keyframes pulse {
50% {
opacity: .5;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
.cursor-not-allowed {
cursor: not-allowed;
}
.cursor-pointer {
cursor: pointer;
}
.resize-none {
resize: none;
}
.snap-x {
scroll-snap-type: x var(--tw-scroll-snap-strictness);
}
.snap-mandatory {
--tw-scroll-snap-strictness: mandatory;
}
.snap-center {
scroll-snap-align: center;
}
.list-decimal {
list-style-type: decimal;
}
.list-disc {
list-style-type: disc;
}
.grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.flex-col {
flex-direction: column;
}
.flex-wrap {
flex-wrap: wrap;
}
.items-start {
align-items: flex-start;
}
.items-end {
align-items: flex-end;
}
.items-center {
align-items: center;
}
.items-baseline {
align-items: baseline;
}
.justify-end {
justify-content: flex-end;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.justify-around {
justify-content: space-around;
}
.gap-2 {
gap: 0.5rem;
}
.gap-3 {
gap: 0.75rem;
}
.gap-4 {
gap: 1rem;
}
.-space-x-2 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(-0.5rem * var(--tw-space-x-reverse));
margin-left: calc(-0.5rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-2 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.5rem * var(--tw-space-x-reverse));
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-3 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.75rem * var(--tw-space-x-reverse));
margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-4 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(1rem * var(--tw-space-x-reverse));
margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-6 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(1.5rem * var(--tw-space-x-reverse));
margin-left: calc(1.5rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-8 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(2rem * var(--tw-space-x-reverse));
margin-left: calc(2rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-y-1 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.25rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0.25rem * var(--tw-space-y-reverse));
}
.space-y-2 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0.5rem * var(--tw-space-y-reverse));
}
.space-y-3 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0.75rem * var(--tw-space-y-reverse));
}
.space-y-4 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(1rem * var(--tw-space-y-reverse));
}
.space-y-6 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
}
.overflow-hidden {
overflow: hidden;
}
.overflow-x-auto {
overflow-x: auto;
}
.overflow-x-scroll {
overflow-x: scroll;
}
.whitespace-nowrap {
white-space: nowrap;
}
.whitespace-pre-line {
white-space: pre-line;
}
.rounded {
border-radius: 0.25rem;
}
.rounded-full {
border-radius: 9999px;
}
.rounded-lg {
border-radius: 0.5rem;
}
.rounded-md {
border-radius: 0.375rem;
}
.rounded-xl {
border-radius: 0.75rem;
}
.rounded-b-3xl {
border-bottom-right-radius: 1.5rem;
border-bottom-left-radius: 1.5rem;
}
.border {
border-width: 1px;
}
.border-2 {
border-width: 2px;
}
.border-4 {
border-width: 4px;
}
.border-b {
border-bottom-width: 1px;
}
.border-b-2 {
border-bottom-width: 2px;
}
.border-l-2 {
border-left-width: 2px;
}
.border-r {
border-right-width: 1px;
}
.border-t {
border-top-width: 1px;
}
.border-none {
border-style: none;
}
.border-gray-100 {
--tw-border-opacity: 1;
border-color: rgb(243 244 246 / var(--tw-border-opacity, 1));
}
.border-gray-200 {
--tw-border-opacity: 1;
border-color: rgb(229 231 235 / var(--tw-border-opacity, 1));
}
.border-gray-300 {
--tw-border-opacity: 1;
border-color: rgb(209 213 219 / var(--tw-border-opacity, 1));
}
.border-green-200 {
--tw-border-opacity: 1;
border-color: rgb(187 247 208 / var(--tw-border-opacity, 1));
}
.border-green-500 {
--tw-border-opacity: 1;
border-color: rgb(34 197 94 / var(--tw-border-opacity, 1));
}
.border-green-600 {
--tw-border-opacity: 1;
border-color: rgb(22 163 74 / var(--tw-border-opacity, 1));
}
.border-red-400 {
--tw-border-opacity: 1;
border-color: rgb(248 113 113 / var(--tw-border-opacity, 1));
}
.border-transparent {
border-color: transparent;
}
.border-white {
--tw-border-opacity: 1;
border-color: rgb(255 255 255 / var(--tw-border-opacity, 1));
}
.border-white\/30 {
border-color: rgb(255 255 255 / 0.3);
}
.border-t-transparent {
border-top-color: transparent;
}
.bg-amber-500\/90 {
background-color: rgb(245 158 11 / 0.9);
}
.bg-blue-100 {
--tw-bg-opacity: 1;
background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1));
}
.bg-gray-100 {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1));
}
.bg-gray-200 {
--tw-bg-opacity: 1;
background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1));
}
.bg-gray-300 {
--tw-bg-opacity: 1;
background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1));
}
.bg-gray-400 {
--tw-bg-opacity: 1;
background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1));
}
.bg-gray-500 {
--tw-bg-opacity: 1;
background-color: rgb(107 114 128 / var(--tw-bg-opacity, 1));
}
.bg-green-100 {
--tw-bg-opacity: 1;
background-color: rgb(220 252 231 / var(--tw-bg-opacity, 1));
}
.bg-green-50 {
--tw-bg-opacity: 1;
background-color: rgb(240 253 244 / var(--tw-bg-opacity, 1));
}
.bg-green-50\/50 {
background-color: rgb(240 253 244 / 0.5);
}
.bg-green-500 {
--tw-bg-opacity: 1;
background-color: rgb(34 197 94 / var(--tw-bg-opacity, 1));
}
.bg-green-600 {
--tw-bg-opacity: 1;
background-color: rgb(22 163 74 / var(--tw-bg-opacity, 1));
}
.bg-orange-100 {
--tw-bg-opacity: 1;
background-color: rgb(255 237 213 / var(--tw-bg-opacity, 1));
}
.bg-orange-500\/30 {
background-color: rgb(249 115 22 / 0.3);
}
.bg-purple-100 {
--tw-bg-opacity: 1;
background-color: rgb(243 232 255 / var(--tw-bg-opacity, 1));
}
.bg-red-100 {
--tw-bg-opacity: 1;
background-color: rgb(254 226 226 / var(--tw-bg-opacity, 1));
}
.bg-red-500 {
--tw-bg-opacity: 1;
background-color: rgb(239 68 68 / var(--tw-bg-opacity, 1));
}
.bg-red-500\/90 {
background-color: rgb(239 68 68 / 0.9);
}
.bg-transparent {
background-color: transparent;
}
.bg-white {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1));
}
.bg-white\/10 {
background-color: rgb(255 255 255 / 0.1);
}
.bg-white\/20 {
background-color: rgb(255 255 255 / 0.2);
}
.bg-white\/30 {
background-color: rgb(255 255 255 / 0.3);
}
.bg-white\/50 {
background-color: rgb(255 255 255 / 0.5);
}
.bg-white\/70 {
background-color: rgb(255 255 255 / 0.7);
}
.bg-white\/80 {
background-color: rgb(255 255 255 / 0.8);
}
.bg-white\/90 {
background-color: rgb(255 255 255 / 0.9);
}
.bg-gradient-to-b {
background-image: linear-gradient(to bottom, var(--tw-gradient-stops));
}
.bg-gradient-to-br {
background-image: linear-gradient(to bottom right, var(--tw-gradient-stops));
}
.bg-gradient-to-r {
background-image: linear-gradient(to right, var(--tw-gradient-stops));
}
.bg-gradient-to-t {
background-image: linear-gradient(to top, var(--tw-gradient-stops));
}
.from-black\/70 {
--tw-gradient-from: rgb(0 0 0 / 0.7) var(--tw-gradient-from-position);
--tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
.from-green-50 {
--tw-gradient-from: #f0fdf4 var(--tw-gradient-from-position);
--tw-gradient-to: rgb(240 253 244 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
.from-green-50\/70 {
--tw-gradient-from: rgb(240 253 244 / 0.7) var(--tw-gradient-from-position);
--tw-gradient-to: rgb(240 253 244 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
.from-green-500 {
--tw-gradient-from: #22c55e var(--tw-gradient-from-position);
--tw-gradient-to: rgb(34 197 94 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
.from-green-500\/10 {
--tw-gradient-from: rgb(34 197 94 / 0.1) var(--tw-gradient-from-position);
--tw-gradient-to: rgb(34 197 94 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
.from-red-500 {
--tw-gradient-from: #ef4444 var(--tw-gradient-from-position);
--tw-gradient-to: rgb(239 68 68 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
.from-red-500\/70 {
--tw-gradient-from: rgb(239 68 68 / 0.7) var(--tw-gradient-from-position);
--tw-gradient-to: rgb(239 68 68 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
.from-transparent {
--tw-gradient-from: transparent var(--tw-gradient-from-position);
--tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
.from-white {
--tw-gradient-from: #fff var(--tw-gradient-from-position);
--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
.via-black\/20 {
--tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), rgb(0 0 0 / 0.2) var(--tw-gradient-via-position), var(--tw-gradient-to);
}
.via-green-100\/30 {
--tw-gradient-to: rgb(220 252 231 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), rgb(220 252 231 / 0.3) var(--tw-gradient-via-position), var(--tw-gradient-to);
}
.via-green-50\/10 {
--tw-gradient-to: rgb(240 253 244 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), rgb(240 253 244 / 0.1) var(--tw-gradient-via-position), var(--tw-gradient-to);
}
.via-teal-50 {
--tw-gradient-to: rgb(240 253 250 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), #f0fdfa var(--tw-gradient-via-position), var(--tw-gradient-to);
}
.to-black\/50 {
--tw-gradient-to: rgb(0 0 0 / 0.5) var(--tw-gradient-to-position);
}
.to-black\/60 {
--tw-gradient-to: rgb(0 0 0 / 0.6) var(--tw-gradient-to-position);
}
.to-black\/70 {
--tw-gradient-to: rgb(0 0 0 / 0.7) var(--tw-gradient-to-position);
}
.to-blue-50 {
--tw-gradient-to: #eff6ff var(--tw-gradient-to-position);
}
.to-blue-50\/10 {
--tw-gradient-to: rgb(239 246 255 / 0.1) var(--tw-gradient-to-position);
}
.to-blue-50\/30 {
--tw-gradient-to: rgb(239 246 255 / 0.3) var(--tw-gradient-to-position);
}
.to-blue-500 {
--tw-gradient-to: #3b82f6 var(--tw-gradient-to-position);
}
.to-blue-500\/10 {
--tw-gradient-to: rgb(59 130 246 / 0.1) var(--tw-gradient-to-position);
}
.to-green-600 {
--tw-gradient-to: #16a34a var(--tw-gradient-to-position);
}
.to-red-600 {
--tw-gradient-to: #dc2626 var(--tw-gradient-to-position);
}
.to-red-600\/90 {
--tw-gradient-to: rgb(220 38 38 / 0.9) var(--tw-gradient-to-position);
}
.to-transparent {
--tw-gradient-to: transparent var(--tw-gradient-to-position);
}
.to-white\/90 {
--tw-gradient-to: rgb(255 255 255 / 0.9) var(--tw-gradient-to-position);
}
.bg-cover {
background-size: cover;
}
.bg-center {
background-position: center;
}
.object-cover {
-o-object-fit: cover;
object-fit: cover;
}
.p-1 {
padding: 0.25rem;
}
.p-2 {
padding: 0.5rem;
}
.p-3 {
padding: 0.75rem;
}
.p-4 {
padding: 1rem;
}
.p-6 {
padding: 1.5rem;
}
.px-1 {
padding-left: 0.25rem;
padding-right: 0.25rem;
}
.px-1\.5 {
padding-left: 0.375rem;
padding-right: 0.375rem;
}
.px-2 {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.px-3 {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.px-4 {
padding-left: 1rem;
padding-right: 1rem;
}
.px-6 {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.py-0\.5 {
padding-top: 0.125rem;
padding-bottom: 0.125rem;
}
.py-1 {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
}
.py-1\.5 {
padding-top: 0.375rem;
padding-bottom: 0.375rem;
}
.py-10 {
padding-top: 2.5rem;
padding-bottom: 2.5rem;
}
.py-12 {
padding-top: 3rem;
padding-bottom: 3rem;
}
.py-2 {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.py-3 {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
}
.py-4 {
padding-top: 1rem;
padding-bottom: 1rem;
}
.py-8 {
padding-top: 2rem;
padding-bottom: 2rem;
}
.pb-1 {
padding-bottom: 0.25rem;
}
.pb-16 {
padding-bottom: 4rem;
}
.pb-2 {
padding-bottom: 0.5rem;
}
.pb-20 {
padding-bottom: 5rem;
}
.pb-24 {
padding-bottom: 6rem;
}
.pb-3 {
padding-bottom: 0.75rem;
}
.pb-4 {
padding-bottom: 1rem;
}
.pb-8 {
padding-bottom: 2rem;
}
.pl-3 {
padding-left: 0.75rem;
}
.pl-5 {
padding-left: 1.25rem;
}
.pt-3 {
padding-top: 0.75rem;
}
.pt-4 {
padding-top: 1rem;
}
.pt-6 {
padding-top: 1.5rem;
}
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.text-2xl {
font-size: 1.5rem;
line-height: 2rem;
}
.text-3xl {
font-size: 1.875rem;
line-height: 2.25rem;
}
.text-base {
font-size: 1rem;
line-height: 1.5rem;
}
.text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
}
.text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
.text-xl {
font-size: 1.25rem;
line-height: 1.75rem;
}
.text-xs {
font-size: 0.75rem;
line-height: 1rem;
}
.font-bold {
font-weight: 700;
}
.font-medium {
font-weight: 500;
}
.font-semibold {
font-weight: 600;
}
.text-amber-400 {
--tw-text-opacity: 1;
color: rgb(251 191 36 / var(--tw-text-opacity, 1));
}
.text-blue-500 {
--tw-text-opacity: 1;
color: rgb(59 130 246 / var(--tw-text-opacity, 1));
}
.text-blue-600 {
--tw-text-opacity: 1;
color: rgb(37 99 235 / var(--tw-text-opacity, 1));
}
.text-blue-700 {
--tw-text-opacity: 1;
color: rgb(29 78 216 / var(--tw-text-opacity, 1));
}
.text-gray-300 {
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity, 1));
}
.text-gray-400 {
--tw-text-opacity: 1;
color: rgb(156 163 175 / var(--tw-text-opacity, 1));
}
.text-gray-500 {
--tw-text-opacity: 1;
color: rgb(107 114 128 / var(--tw-text-opacity, 1));
}
.text-gray-600 {
--tw-text-opacity: 1;
color: rgb(75 85 99 / var(--tw-text-opacity, 1));
}
.text-gray-700 {
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity, 1));
}
.text-gray-800 {
--tw-text-opacity: 1;
color: rgb(31 41 55 / var(--tw-text-opacity, 1));
}
.text-gray-900 {
--tw-text-opacity: 1;
color: rgb(17 24 39 / var(--tw-text-opacity, 1));
}
.text-green-500 {
--tw-text-opacity: 1;
color: rgb(34 197 94 / var(--tw-text-opacity, 1));
}
.text-green-600 {
--tw-text-opacity: 1;
color: rgb(22 163 74 / var(--tw-text-opacity, 1));
}
.text-green-700 {
--tw-text-opacity: 1;
color: rgb(21 128 61 / var(--tw-text-opacity, 1));
}
.text-orange-100 {
--tw-text-opacity: 1;
color: rgb(255 237 213 / var(--tw-text-opacity, 1));
}
.text-orange-300 {
--tw-text-opacity: 1;
color: rgb(253 186 116 / var(--tw-text-opacity, 1));
}
.text-orange-500 {
--tw-text-opacity: 1;
color: rgb(249 115 22 / var(--tw-text-opacity, 1));
}
.text-orange-600 {
--tw-text-opacity: 1;
color: rgb(234 88 12 / var(--tw-text-opacity, 1));
}
.text-orange-700 {
--tw-text-opacity: 1;
color: rgb(194 65 12 / var(--tw-text-opacity, 1));
}
.text-pink-500 {
--tw-text-opacity: 1;
color: rgb(236 72 153 / var(--tw-text-opacity, 1));
}
.text-purple-600 {
--tw-text-opacity: 1;
color: rgb(147 51 234 / var(--tw-text-opacity, 1));
}
.text-red-500 {
--tw-text-opacity: 1;
color: rgb(239 68 68 / var(--tw-text-opacity, 1));
}
.text-red-700 {
--tw-text-opacity: 1;
color: rgb(185 28 28 / var(--tw-text-opacity, 1));
}
.text-white {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
}
.text-white\/80 {
color: rgb(255 255 255 / 0.8);
}
.text-white\/90 {
color: rgb(255 255 255 / 0.9);
}
.text-yellow-400 {
--tw-text-opacity: 1;
color: rgb(250 204 21 / var(--tw-text-opacity, 1));
}
.line-through {
text-decoration-line: line-through;
}
.placeholder-gray-400::-moz-placeholder {
--tw-placeholder-opacity: 1;
color: rgb(156 163 175 / var(--tw-placeholder-opacity, 1));
}
.placeholder-gray-400::placeholder {
--tw-placeholder-opacity: 1;
color: rgb(156 163 175 / var(--tw-placeholder-opacity, 1));
}
.opacity-15 {
opacity: 0.15;
}
.opacity-70 {
opacity: 0.7;
}
.shadow-lg {
--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.shadow-md {
--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.shadow-sm {
--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.outline-none {
outline: 2px solid transparent;
outline-offset: 2px;
}
.blur {
--tw-blur: blur(8px);
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
.drop-shadow-md {
--tw-drop-shadow: drop-shadow(0 4px 3px rgb(0 0 0 / 0.07)) drop-shadow(0 2px 2px rgb(0 0 0 / 0.06));
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
.drop-shadow-sm {
--tw-drop-shadow: drop-shadow(0 1px 1px rgb(0 0 0 / 0.05));
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
.filter {
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
.backdrop-blur-lg {
--tw-backdrop-blur: blur(16px);
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
}
.backdrop-blur-md {
--tw-backdrop-blur: blur(12px);
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
}
.backdrop-blur-sm {
--tw-backdrop-blur: blur(4px);
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
}
.backdrop-filter {
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
}
.transition-colors {
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
/* Custom fonts */
/* Base styles */
:root {
--primary-color: #4caf50;
--secondary-color: #2196f3;
--background-start: #f0f9ef;
--background-end: #e6f4ff;
--text-color: #333333;
--text-light: #6e6e6e;
}
body {
font-family: 'Noto Sans SC', sans-serif;
--tw-text-opacity: 1;
color: rgb(31 41 55 / var(--tw-text-opacity, 1));
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
::-webkit-scrollbar-track {
background-color: transparent;
}
::-webkit-scrollbar-thumb {
border-radius: 25px;
-webkit-transition: all 0.3s;
transition: all 0.3s;
background-color: rgba(106, 115, 125, 0.2);
&:hover {
background-color: rgba(106, 115, 125, 0.27);
}
}
::-webkit-scrollbar-corner {
display: none;
}
/* Custom frosted glass components */
.frosted-glass {
border-radius: 0.75rem;
border-width: 1px;
border-color: rgb(255 255 255 / 0.3);
background-color: rgb(255 255 255 / 0.2);
--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
--tw-backdrop-blur: blur(12px);
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
}
.frosted-card {
padding: 1rem;
border-radius: 0.75rem;
border-width: 1px;
border-color: rgb(255 255 255 / 0.3);
background-color: rgb(255 255 255 / 0.2);
--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
--tw-backdrop-blur: blur(12px);
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
}
.gradient-bg {
background-image: linear-gradient(to bottom right, var(--tw-gradient-stops));
--tw-gradient-from: #f0fdf4 var(--tw-gradient-from-position);
--tw-gradient-to: rgb(240 253 244 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
--tw-gradient-to: rgb(240 253 250 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), #f0fdfa var(--tw-gradient-via-position), var(--tw-gradient-to);
--tw-gradient-to: #eff6ff var(--tw-gradient-to-position);
}
/* Custom animations */
.fade-in {
animation: fadeIn 0.5s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Button styles */
.btn-primary {
border-radius: 9999px;
background-image: linear-gradient(to right, var(--tw-gradient-stops));
--tw-gradient-from: #22c55e var(--tw-gradient-from-position);
--tw-gradient-to: rgb(34 197 94 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
--tw-gradient-to: #10b981 var(--tw-gradient-to-position);
padding-top: 0.5rem;
padding-bottom: 0.5rem;
padding-left: 1rem;
padding-right: 1rem;
font-weight: 500;
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
}
.btn-primary:hover {
--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.btn-secondary {
border-radius: 9999px;
border-width: 1px;
--tw-border-opacity: 1;
border-color: rgb(243 244 246 / var(--tw-border-opacity, 1));
background-color: rgb(255 255 255 / 0.7);
padding-top: 0.5rem;
padding-bottom: 0.5rem;
padding-left: 1rem;
padding-right: 1rem;
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity, 1));
--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
--tw-backdrop-blur: blur(4px);
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
}
.btn-secondary:hover {
--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.btn-outline {
border-radius: 9999px;
border-width: 1px;
--tw-border-opacity: 1;
border-color: rgb(34 197 94 / var(--tw-border-opacity, 1));
background-color: transparent;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
padding-left: 1rem;
padding-right: 1rem;
--tw-text-opacity: 1;
color: rgb(34 197 94 / var(--tw-text-opacity, 1));
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
}
.btn-outline:hover {
--tw-bg-opacity: 1;
background-color: rgb(240 253 244 / var(--tw-bg-opacity, 1));
}
/* Badge styles */
.badge {
border-radius: 9999px;
padding-left: 0.5rem;
padding-right: 0.5rem;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
font-size: 0.75rem;
line-height: 1rem;
font-weight: 500;
}
.badge-success {
--tw-bg-opacity: 1;
background-color: rgb(220 252 231 / var(--tw-bg-opacity, 1));
--tw-text-opacity: 1;
color: rgb(21 128 61 / var(--tw-text-opacity, 1));
}
.badge-info {
--tw-bg-opacity: 1;
background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1));
--tw-text-opacity: 1;
color: rgb(29 78 216 / var(--tw-text-opacity, 1));
}
.badge-warning {
--tw-bg-opacity: 1;
background-color: rgb(255 237 213 / var(--tw-bg-opacity, 1));
--tw-text-opacity: 1;
color: rgb(194 65 12 / var(--tw-text-opacity, 1));
}
/* Custom utilities */
.text-gradient {
background-image: linear-gradient(to right, var(--tw-gradient-stops));
--tw-gradient-from: #16a34a var(--tw-gradient-from-position);
--tw-gradient-to: rgb(22 163 74 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
--tw-gradient-to: #2563eb var(--tw-gradient-to-position);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.card-shadow {
box-shadow: 0 8px 20px -8px rgba(0, 0, 0, 0.1);
}
/* Price tag */
.price-tag {
font-weight: 600;
--tw-text-opacity: 1;
color: rgb(249 115 22 / var(--tw-text-opacity, 1));
}
.last\:border-b-0:last-child {
border-bottom-width: 0px;
}
.hover\:bg-gray-50:hover {
--tw-bg-opacity: 1;
background-color: rgb(249 250 251 / var(--tw-bg-opacity, 1));
}
.hover\:bg-white:hover {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1));
}
.hover\:bg-white\/30:hover {
background-color: rgb(255 255 255 / 0.3);
}
.hover\:from-green-600:hover {
--tw-gradient-from: #16a34a var(--tw-gradient-from-position);
--tw-gradient-to: rgb(22 163 74 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
.hover\:to-green-700:hover {
--tw-gradient-to: #15803d var(--tw-gradient-to-position);
}
.hover\:text-green-500:hover {
--tw-text-opacity: 1;
color: rgb(34 197 94 / var(--tw-text-opacity, 1));
}
.focus\:border-green-500:focus {
--tw-border-opacity: 1;
border-color: rgb(34 197 94 / var(--tw-border-opacity, 1));
}
.focus\:outline-none:focus {
outline: 2px solid transparent;
outline-offset: 2px;
}
.focus\:ring-1:focus {
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
}
.focus\:ring-2:focus {
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
}
.focus\:ring-green-500:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity, 1));
}
.focus\:ring-offset-2:focus {
--tw-ring-offset-width: 2px;
}
.active\:bg-white\/50:active {
background-color: rgb(255 255 255 / 0.5);
}
.disabled\:opacity-70:disabled {
opacity: 0.7;
}
@media (min-width: 640px) {
.sm\:mx-auto {
margin-left: auto;
margin-right: auto;
}
.sm\:w-full {
width: 100%;
}
.sm\:max-w-md {
max-width: 28rem;
}
.sm\:px-6 {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
@media (min-width: 1024px) {
.lg\:px-8 {
padding-left: 2rem;
padding-right: 2rem;
}
}
/**
* Mock data for the parent-child education app
* This file contains sample data for courses, activities, and users
*/
// Featured course for the top banner
export const featuredCourse = {
id: 'featured-1',
title: '传承之道',
subtitle: '大理鸡足山游学',
imageUrl: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
liveTime: '14:00:00'
};
// User recommendations
export const userRecommendations = [
{ title: "亲子阅读技巧入门", duration: "15分钟", image: "https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg" },
{ title: "3-6岁孩子的情绪管理", duration: "20分钟", image: "https://cdn.ipadbiz.cn/mlaj/images/27kCu7bXGEI.jpg" },
{ title: "趣味数学启蒙课", duration: "12分钟", image: "https://cdn.ipadbiz.cn/mlaj/images/jbwr0qZvpD4.jpg" },
{ title: "儿童英语绘本故事", duration: "18分钟", image: "https://cdn.ipadbiz.cn/mlaj/images/GGCP6vshpPY.jpg" }
];
// Live streaming sessions
export const liveStreams = [
{
id: 'live-1',
title: '无界之世',
subtitle: '敦煌行',
imageUrl: 'https://cdn.ipadbiz.cn/mlaj/images/2JIvboGLeho.jpg',
isLive: true,
viewers: 272
},
{
id: 'live-2',
title: '慧眼读书会',
subtitle: '',
imageUrl: 'https://cdn.ipadbiz.cn/mlaj/images/_6HzPU9Hyfg (2).jpg',
isLive: true,
viewers: 272
}
];
// Course list data
export const courses = [
{
id: 'course-1',
title: '好分凭借力,陪你跃龙门!',
subtitle: '4.17-6.18 美乐考前赋能营',
imageUrl: 'https://cdn.ipadbiz.cn/mlaj/images/jbwr0qZvpD4.jpg', // Updated with new image
price: 365,
updatedLessons: 16,
subscribers: 1140,
expireDate: '2025-04-17 00:00:00',
description: `【美乐考前赋能营】
结合平台多年亲子教育的实践经验,
针对近在眼前的高考,
和为学业考试而焦虑的群体
精心打造、最为定制的专项课程。
旨在帮助家长更好地理解,支持孩子,
有效管理自己的情绪和压力;
进而引导孩子的情绪状态,
以最佳的心理状态迎接挑战。
在考试的最后冲刺阶段,
给世界一个爱赏识他们的理由,
共同助力每个学子梦想成真!`,
sections: [
{ title: '课程介绍', content: '课程详细描述内容...' },
{ title: '课程目录', content: '第1章:心态准备\n第2章:考前减压\n第3章:家庭支持' }
]
},
{
id: 'course-2',
title: '大国少年-梦想嘉年华',
subtitle: '亲子冬令营',
imageUrl: 'https://cdn.ipadbiz.cn/mlaj/images/GGCP6vshpPY.jpg', // Updated with new image
price: 3665,
updatedLessons: 16,
subscribers: 1140
},
{
id: 'course-3',
title: '大国少年-世界正东方',
subtitle: '亲子夏令营',
imageUrl: 'https://cdn.ipadbiz.cn/mlaj/images/2Juj2cXWB7U.jpg', // Updated with new image
price: 1280,
updatedLessons: 16,
subscribers: 1140
}
];
// Activity data
export const activities = [
{
id: 'activity-1',
title: '慧眼读书 | 《论语》',
subtitle: '亲子共读',
imageUrl: 'https://cdn.ipadbiz.cn/mlaj/images/2JIvboGLeho.jpg', // Updated with new image
location: '北京',
status: '活动中',
period: '2024.12.09-2025.01.17',
price: 99,
originalPrice: 120,
isHot: true,
isFree: false,
participantsCount: 18,
maxParticipants: 30
},
{
id: 'activity-2',
title: '好分凭借力,陪你跃龙门!',
subtitle: '4.17-6.18 美乐考前赋能营',
imageUrl: 'https://cdn.ipadbiz.cn/mlaj/images/_6HzPU9Hyfg (2).jpg', // Updated with new image
location: '线上',
status: '活动中',
period: '2024.12.17-2025.01.26',
price: 366,
originalPrice: 468,
isHot: false,
isFree: false,
participantsCount: 25,
maxParticipants: 50
},
{
id: 'activity-3',
title: '7.29-8.4 敦煌: 【青云之路】',
subtitle: '亲子游学营',
imageUrl: 'https://cdn.ipadbiz.cn/mlaj/images/Y17FE9Fuw4Y.jpg', // Updated with new image
location: '敦煌',
status: '即将开始',
period: '2025.01.07-2025.01.26',
price: 3980,
originalPrice: 4200,
isHot: true,
isFree: false,
participantsCount: 12,
maxParticipants: 20
},
{
id: 'activity-4',
title: '【大国少年·梦想嘉年华】',
subtitle: '亲子冬令营',
imageUrl: 'https://cdn.ipadbiz.cn/mlaj/images/-G3rw6Y02D0.jpg',
location: '上海',
status: '进行中',
period: '2025.01.09-2025.01.22',
price: 1280,
originalPrice: 1380,
isHot: false,
isFree: false,
participantsCount: 24,
maxParticipants: 40
},
{
id: 'activity-5',
title: '慧眼读书会 |《零极限》',
subtitle: '共读',
imageUrl: 'https://cdn.ipadbiz.cn/mlaj/images/Oalh2MojUuk.jpg',
location: '线上',
status: '进行中',
period: '2025.01.09-2025.01.22',
price: 0,
originalPrice: 0,
isHot: false,
isFree: true,
participantsCount: 45,
maxParticipants: 100
}
];
// User profile data
export const userProfile = {
name: '李玉红',
avatar: 'https://cdn.ipadbiz.cn/mlaj/images/user-avatar-2.jpg',
activities: [],
courses: [],
children: [],
checkIns: {
totalDays: 45,
currentStreak: 7,
longestStreak: 15
}
};
// Daily check-in data
export const checkInTypes = [
{ id: 'reading', name: '阅读打卡', icon: 'book' },
{ id: 'exercise', name: '运动打卡', icon: 'running' },
{ id: 'study', name: '学习打卡', icon: 'graduation-cap' },
{ id: 'reflection', name: '反思打卡', icon: 'pencil-alt' }
];
// Community posts data
export const communityPosts = [
{
id: 'post-1',
author: {
id: 'user-1',
name: '王小明',
avatar: 'https://cdn.ipadbiz.cn/mlaj/images/user-avatar-3.jpg'
},
content: '今天和孩子一起完成了《论语》的共读,收获颇丰!孩子对"己所不欲勿施于人"这句话有了更深的理解。',
images: ['https://cdn.ipadbiz.cn/mlaj/images/post-1-1.jpg', 'https://cdn.ipadbiz.cn/mlaj/images/post-1-2.jpg'],
likes: 24,
comments: 5,
createdAt: '2023-03-15T08:30:00Z'
},
{
id: 'post-2',
author: {
id: 'user-2',
name: '李晓华',
avatar: 'https://cdn.ipadbiz.cn/mlaj/images/user-avatar-1.jpg'
},
content: '冬令营第三天,孩子们参观了科技馆,学习了很多有趣的知识,晚上的篝火晚会也很精彩!',
images: ['https://cdn.ipadbiz.cn/mlaj/images/post-2-1.jpg'],
likes: 36,
comments: 8,
createdAt: '2023-03-14T15:45:00Z'
}
];
<script setup>
import { Button, NavBar, Tabbar, TabbarItem } from 'vant';
</script>
<template>
<div class="min-h-screen flex flex-col bg-gray-100">
<van-nav-bar
title="关于"
left-text="返回"
right-text="菜单"
left-arrow
@click-left="onClickLeft"
@click-right="onClickRight"
/>
<div class="flex-1 p-4">
<div class="bg-white rounded-lg shadow p-4 mb-4">
<h2 class="text-xl font-bold mb-2">关于我们</h2>
<p class="text-gray-600 mb-4">这是一个示例项目的关于页面,展示了如何使用Vue3、Vite、Vant4和TailwindCSS构建现代化的Web应用。</p>
<van-button type="primary" block>了解更多</van-button>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="bg-white rounded-lg shadow p-4">
<h3 class="text-lg font-semibold mb-2">联系方式</h3>
<ul class="text-gray-600">
<li>邮箱:example@example.com</li>
<li>电话:123-456-7890</li>
<li>地址:示例地址</li>
</ul>
</div>
<div class="bg-white rounded-lg shadow p-4">
<h3 class="text-lg font-semibold mb-2">版本信息</h3>
<ul class="text-gray-600">
<li>当前版本:1.0.0</li>
<li>更新日期:2024-03-20</li>
<li>开源协议:MIT</li>
</ul>
</div>
</div>
</div>
<van-tabbar v-model="active">
<van-tabbar-item icon="home-o">首页</van-tabbar-item>
<van-tabbar-item icon="search">搜索</van-tabbar-item>
<van-tabbar-item icon="friends-o">好友</van-tabbar-item>
<van-tabbar-item icon="setting-o">设置</van-tabbar-item>
</van-tabbar>
</div>
</template>
<script>
export default {
data() {
return {
active: 0
}
},
methods: {
onClickLeft() {
this.$router.back()
},
onClickRight() {
// 处理右侧按钮点击
}
}
}
</script>
<!--
* @Date: 2025-03-20 19:55:21
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-03-20 20:26:17
* @FilePath: /vue-vite/src/views/Home.vue
* @Description: 文件描述
-->
<template>
<AppLayout title="亲子学院" :rightContent="rightContent">
<div class="pb-16 bg-gradient-to-b from-white via-green-50/10 to-blue-50/10">
<!-- Header Section with Welcome & Weather -->
<div class="px-4 pt-3 pb-4">
<FrostedGlass class="p-4 rounded-xl mb-4">
<div class="flex justify-between items-center mb-3">
<div class="flex items-center">
<div class="w-10 h-10 rounded-full overflow-hidden mr-3">
<img
src="https://cdn.ipadbiz.cn/mlaj/images/user-avatar-2.jpg"
alt="王小明"
class="w-full h-full object-cover"
@error="handleImageError" />
</div>
<div>
<h2 class="text-xl font-bold">欢迎回来,小明!</h2>
<p class="text-sm text-gray-500">{{ formatToday() }}</p>
</div>
</div>
<div class="flex items-center text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<span class="ml-1 font-medium">23°C</span>
<span class="ml-1 text-gray-500">晴朗</span>
</div>
</div>
<!-- User Stats -->
<div class="flex justify-between text-center py-2">
<div class="border-r border-gray-200 flex-1">
<div class="text-lg font-bold">3</div>
<div class="text-xs text-gray-500">连续打卡</div>
</div>
<div class="border-r border-gray-200 flex-1">
<div class="text-lg font-bold">12</div>
<div class="text-xs text-gray-500">已学课程</div>
</div>
<div class="flex-1">
<div class="text-lg font-bold">25</div>
<div class="text-xs text-gray-500">积分</div>
</div>
</div>
</FrostedGlass>
<!-- Daily Check-in -->
<FrostedGlass class="p-4 rounded-xl">
<div class="flex justify-between items-center mb-3">
<h3 class="font-medium">今日打卡</h3>
<router-link to="/profile" class="text-green-600 text-sm">打卡记录</router-link>
</div>
<div v-if="checkInSuccess" class="bg-green-50 border border-green-200 rounded-lg p-4 text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-green-500 mx-auto mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h4 class="text-green-700 font-medium mb-1">打卡成功!</h4>
<p class="text-green-600 text-sm">+5 积分已添加到您的账户</p>
</div>
<template v-else>
<div class="flex space-x-3 overflow-x-auto py-2">
<button
v-for="checkInType in checkInTypes"
:key="checkInType.id"
:class="[
'flex-shrink-0 flex flex-col items-center p-3 rounded-lg transition-colors',
selectedCheckIn?.id === checkInType.id
? 'bg-green-100 border border-green-200'
: 'bg-white/70 border border-gray-100 hover:bg-white'
]"
@click="handleCheckInSelect(checkInType)"
>
<div :class="[
'w-12 h-12 rounded-full flex items-center justify-center mb-1 transition-colors',
selectedCheckIn?.id === checkInType.id
? 'bg-green-500 text-white'
: 'bg-gray-100 text-gray-500'
]">
<svg v-if="checkInType.id === 'reading'" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<svg v-if="checkInType.id === 'exercise'" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<svg v-if="checkInType.id === 'study'" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path d="M12 14l9-5-9-5-9 5 9 5z" />
<path d="M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998a12.078 12.078 0 01.665-6.479L12 14z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 14l9-5-9-5-9 5 9 5zm0 0l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998a12.078 12.078 0 01.665-6.479L12 14zm-4 6v-7.5l4-2.222" />
</svg>
<svg v-if="checkInType.id === 'reflection'" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</div>
<span class="text-xs">{{ checkInType.name }}</span>
</button>
</div>
<div v-if="selectedCheckIn" class="mt-3">
<textarea
:placeholder="`请输入${selectedCheckIn.name}内容...`"
v-model="checkInContent"
class="w-full p-3 border border-gray-200 rounded-lg text-sm resize-none h-24"
/>
<button
class="mt-2 w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-2 rounded-lg flex items-center justify-center"
@click="handleCheckInSubmit"
:disabled="isCheckingIn"
>
<template v-if="isCheckingIn">
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></div>
提交中...
</template>
<template v-else>提交打卡</template>
</button>
</div>
</template>
</FrostedGlass>
</div>
<!-- Summer Camp Promotion -->
<div class="px-4 mb-6">
<SummerCampCard />
</div>
<!-- Featured Courses Carousel -->
<div class="mb-6">
<div class="px-4 mb-2">
<h3 class="font-medium">精选课程</h3>
</div>
<div class="relative">
<div
ref="carouselRef"
class="flex overflow-x-scroll snap-x snap-mandatory"
style="scrollbar-width: none; -ms-overflow-style: none;"
>
<div
v-for="(course, index) in courses.slice(0, 4)"
:key="course.id"
class="flex-shrink-0 w-full snap-center px-4"
>
<div class="relative rounded-xl overflow-hidden shadow-lg h-48">
<img
:src="course.imageUrl || 'https://cdn.ipadbiz.cn/mlaj/images/featured-course.jpg'"
:alt="course.title"
class="w-full h-full object-cover"
/>
<div class="absolute inset-0 bg-gradient-to-b from-transparent via-black/20 to-black/60 flex flex-col justify-end p-4">
<div class="bg-amber-500/90 text-white px-2 py-1 rounded-full text-xs font-medium inline-block w-fit mb-1">
{{ course.category }}
</div>
<h2 class="text-2xl font-bold text-white drop-shadow-md">{{ course.title }}</h2>
<p class="text-white/90 text-sm drop-shadow-sm mb-1">{{ course.subtitle }}</p>
<div class="flex justify-between items-center">
<div class="flex items-center">
<div class="flex">
<svg
v-for="i in 5"
:key="i"
xmlns="http://www.w3.org/2000/svg"
:class="[`h-4 w-4`, i <= course.rating ? 'text-amber-400' : 'text-gray-300']"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</div>
<span class="text-white text-xs ml-1">{{ course.ratingCount }}人评</span>
</div>
<router-link
:to="`/courses/${course.id}`"
class="bg-white/90 text-green-600 px-3 py-1 rounded-full text-xs font-medium"
>
立即学习
</router-link>
</div>
</div>
</div>
</div>
</div>
<!-- Carousel Indicators -->
<div class="flex justify-center mt-4">
<button
v-for="(_, index) in courses.slice(0, 4)"
:key="index"
@click="scrollToSlide(index)"
:class="[
'w-2 h-2 mx-1 rounded-full',
currentSlide === index ? 'bg-green-600' : 'bg-gray-300'
]"
/>
</div>
</div>
</div>
<!-- Tab Navigation -->
<div class="px-4 border-b border-gray-200">
<div class="flex space-x-6">
<button
v-for="tab in ['推荐', '直播', '精选']"
:key="tab"
@click="activeTab = tab"
:class="[
'pb-3 px-1 font-medium',
activeTab === tab
? 'text-green-600 border-b-2 border-green-600'
: 'text-gray-500'
]"
>
{{ tab }}
<span
v-if="tab === '直播'"
class="ml-1 px-1.5 py-0.5 bg-red-500 text-white text-xs rounded-full"
>
2
</span>
</button>
</div>
</div>
<!-- Content Based on Active Tab -->
<div class="px-4 mt-5">
<!-- Recommended Content -->
<div v-if="activeTab === '推荐'">
<!-- Personalized Recommendations -->
<section class="mb-7">
<div class="flex justify-between items-center mb-3">
<h3 class="font-medium">为您推荐</h3>
<button class="text-xs text-gray-500 flex items-center">
换一批
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
<div class="grid grid-cols-2 gap-4">
<FrostedGlass
v-for="(item, index) in userRecommendations"
:key="index"
class="p-3 rounded-xl"
>
<div class="flex flex-col h-full">
<div class="h-28 mb-2 rounded-lg overflow-hidden">
<img
:src="item.image"
:alt="item.title"
class="w-full h-full object-cover"
@error="handleImageError"
/>
</div>
<h4 class="font-medium text-sm mb-1 line-clamp-1">{{ item.title }}</h4>
<p class="text-xs text-gray-500 flex items-center mt-auto">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ item.duration }}
</p>
</div>
</FrostedGlass>
</div>
</section>
<!-- Recent Activities -->
<section class="mb-7">
<div class="flex justify-between items-center mb-3">
<h3 class="font-medium">最新活动</h3>
<router-link to="/activities" class="text-xs text-gray-500 flex items-center">
更多
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</router-link>
</div>
<div class="space-y-4">
<ActivityCard
v-for="activity in activities.slice(0, 3)"
:key="activity.id"
:activity="activity"
/>
</div>
</section>
<!-- Popular Courses -->
<section>
<div class="flex justify-between items-center mb-3">
<h3 class="font-medium">热门课程</h3>
<router-link to="/courses" class="text-xs text-gray-500 flex items-center">
更多
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</router-link>
</div>
<div class="space-y-4">
<CourseCard
v-for="course in courses.slice(0, 3)"
:key="course.id"
:course="course"
/>
</div>
</section>
</div>
<!-- Live Content -->
<div v-if="activeTab === '直播'">
<section>
<div class="flex justify-between items-center mb-3">
<h3 class="font-medium">正在直播</h3>
<div class="text-xs text-red-500 flex items-center">
<div class="w-2 h-2 bg-red-500 rounded-full mr-1 animate-pulse"></div>
2个直播中
</div>
</div>
<div class="grid grid-cols-2 gap-4 mb-7">
<LiveStreamCard
v-for="stream in liveStreams"
:key="stream.id"
:stream="stream"
/>
</div>
<div class="mb-5">
<div class="flex justify-between items-center mb-3">
<h3 class="font-medium">直播日历</h3>
<router-link to="/live-calendar" class="text-xs text-blue-500">
查看日历
</router-link>
</div>
<FrostedGlass class="p-3 rounded-xl">
<div class="flex space-x-2 overflow-x-auto py-1">
<div
v-for="(day, i) in ['今天', '明天', '周三', '周四', '周五', '周六', '周日']"
:key="day"
:class="[
'flex-shrink-0 w-10 h-14 flex flex-col items-center justify-center rounded-lg',
i === 0 ? 'bg-green-500 text-white' : 'bg-white/50'
]"
>
<div class="text-xs">{{ day }}</div>
<div class="font-bold mt-1">{{ new Date().getDate() + i }}</div>
</div>
</div>
</FrostedGlass>
</div>
<div>
<h3 class="font-medium mb-3">直播预告</h3>
<div class="space-y-3">
<FrostedGlass
v-for="(item, index) in [
{ title: '亲子阅读会第1期', time: '今天 19:30-20:30', image: 'https://cdn.ipadbiz.cn/mlaj/images/live-1.jpg' },
{ title: '儿童心理健康讲座', time: '明天 20:00-21:00', image: 'https://cdn.ipadbiz.cn/mlaj/images/live-2.jpg' },
{ title: '家庭教育经验分享', time: '周三 19:00-20:00', image: 'https://cdn.ipadbiz.cn/mlaj/images/live-3.jpg' }
]"
:key="index"
class="p-3 rounded-xl"
>
<div class="flex justify-between items-center">
<div class="flex items-center">
<div class="w-12 h-12 bg-green-100 rounded-lg overflow-hidden mr-3 flex-shrink-0">
<img
:src="item.image"
:alt="item.title"
class="w-full h-full object-cover"
@error="handleImageError"
/>
</div>
<div>
<h4 class="font-medium text-sm">{{ item.title }}</h4>
<p class="text-xs text-gray-500 mt-1">{{ item.time }}</p>
</div>
</div>
<button class="bg-white text-green-600 border border-green-600 px-3 py-1 rounded-full text-xs flex-shrink-0">
预约
</button>
</div>
</FrostedGlass>
</div>
</div>
</section>
</div>
<!-- Featured Content -->
<div v-if="activeTab === '精选'">
<section>
<div class="mb-5">
<h3 class="font-medium mb-3">精选内容</h3>
<FrostedGlass class="p-4 rounded-xl">
<div class="flex flex-col">
<div class="inline-block px-2 py-1 bg-blue-100 text-blue-600 text-xs rounded-full mb-2 w-fit">
独家专栏
</div>
<h4 class="font-medium text-lg mb-2">《如何培养孩子的阅读习惯》</h4>
<p class="text-gray-600 text-sm mb-4 line-clamp-2">
阅读习惯的培养是一个长期过程,本文将分享如何从日常生活点滴培养孩子的阅读兴趣和习惯...
</p>
<router-link to="/articles/1" class="text-green-600 text-sm font-medium">
查看完整文章
</router-link>
</div>
</FrostedGlass>
</div>
<div>
<h3 class="font-medium mb-3">推荐视频</h3>
<div class="space-y-4">
<div
v-for="(item, index) in [
{ title: '亲子沟通的艺术', views: '1.2万', duration: '08:25', image: 'https://cdn.ipadbiz.cn/mlaj/images/video-1.jpg' },
{ title: '如何做好家庭教育', views: '8千', duration: '12:40', image: 'https://cdn.ipadbiz.cn/mlaj/images/video-2.jpg' },
{ title: '孩子营养餐制作指南', views: '5千', duration: '15:18', image: 'https://cdn.ipadbiz.cn/mlaj/images/video-3.jpg' }
]"
:key="index"
class="relative rounded-xl overflow-hidden shadow-md h-48"
>
<img
:src="item.image"
:alt="item.title"
class="w-full h-full object-cover"
@error="handleImageError"
/>
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-black/70 flex flex-col justify-end p-4">
<h4 class="text-white font-medium mb-1">{{ item.title }}</h4>
<div class="flex justify-between items-center">
<p class="text-white/80 text-xs">{{ item.views }}次播放 · {{ item.duration }}</p>
<button class="bg-white/20 backdrop-blur-sm p-2 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup lang="jsx">
import { ref, onMounted, onUnmounted, defineComponent, h } from 'vue'
import AppLayout from '@/components/layout/AppLayout.vue'
import FrostedGlass from '@/components/ui/FrostedGlass.vue'
import CourseCard from '@/components/ui/CourseCard.vue'
import LiveStreamCard from '@/components/ui/LiveStreamCard.vue'
import ActivityCard from '@/components/ui/ActivityCard.vue'
import SummerCampCard from '@/components/ui/SummerCampCard.vue'
import { courses, liveStreams, activities, checkInTypes, userRecommendations } from '@/utils/mockData'
// 响应式状态
const activeTab = ref('推荐')
const selectedCheckIn = ref(null)
const checkInContent = ref('')
const currentSlide = ref(0)
const isCheckingIn = ref(false)
const checkInSuccess = ref(false)
const carouselRef = ref(null)
// 右侧内容组件
const RightContent = defineComponent({
setup() {
return () => (
<div class="flex items-center">
<button class="p-2 mr-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
<button class="p-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
</button>
</div>
)
}
})
const rightContent = h(RightContent)
// 图片加载错误处理
const handleImageError = (e) => {
e.target.onerror = null
e.target.src = 'https://cdn.ipadbiz.cn/mlaj/images/user-avatar-2.jpg'
}
// 格式化今天的日期
const formatToday = () => {
const today = new Date()
const options = { month: 'long', day: 'numeric', weekday: 'long' }
return today.toLocaleDateString('zh-CN', options)
}
// 轮播图滚动到指定位置
const scrollToSlide = (index) => {
if (carouselRef.value) {
const slideWidth = carouselRef.value.offsetWidth
carouselRef.value.scrollTo({
left: index * slideWidth,
behavior: 'smooth'
})
currentSlide.value = index
}
}
// 处理打卡类型选择
const handleCheckInSelect = (checkInType) => {
selectedCheckIn.value = checkInType
checkInContent.value = ''
}
// 处理打卡提交
const handleCheckInSubmit = () => {
if (!checkInContent.value.trim()) return
isCheckingIn.value = true
// 模拟API调用
setTimeout(() => {
isCheckingIn.value = false
checkInSuccess.value = true
selectedCheckIn.value = null
checkInContent.value = ''
// 3秒后重置成功提示
setTimeout(() => {
checkInSuccess.value = false
}, 3000)
}, 1500)
}
// 自动轮播
let carouselInterval
onMounted(() => {
carouselInterval = setInterval(() => {
if (carouselRef.value) {
const nextSlide = (currentSlide.value + 1) % courses.slice(0, 4).length
scrollToSlide(nextSlide)
}
}, 5000)
})
onUnmounted(() => {
if (carouselInterval) {
clearInterval(carouselInterval)
}
})
</script>
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
/*
* @Date: 2025-03-20 19:53:12
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-03-20 20:27:29
* @FilePath: /vue-vite/vite.config.js
* @Description: 文件描述
*/
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import path from "path";
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), vueJsx()],
resolve: {
alias: { // 将会被传递到 @rollup/plugin-alias 作为 entries 的选项。也可以是一个对象,或一个 { find, replacement } 的数组. 当使用文件系统路径的别名时,请始终使用绝对路径。相对路径的别名值会被原封不动地使用,因此无法被正常解析。 更高级的自定义解析方法可以通过 插件 实现。
"@": path.resolve(__dirname, "src"),
"@components": path.resolve(__dirname, "src/components"),
"@composables": path.resolve(__dirname, "src/composables"),
"@utils": path.resolve(__dirname, "src/utils"),
"@images": path.resolve(__dirname, "src/assets/images"),
"@css": path.resolve(__dirname, "src/assets/css"),
"@mock": path.resolve(__dirname, "src/assets/mock"),
"common": path.resolve(__dirname, "src/common"),
"@api": path.resolve(__dirname, "src/api"),
},
dedupe: ['vue'], // 如果你在你的应用程序中有相同依赖的副本(比如 monorepos),使用这个选项来强制 Vite 总是将列出的依赖关系解析到相同的副本(从项目根目录)。
// conditions: [''], // 在解析包的 情景导出 时允许的附加条件。
// mainFields: [''], // package.json 中,在解析包的入口点时尝试的字段列表。注意,这比从 exports 字段解析的情景导出优先级低:如果一个入口点从 exports 成功解析,主字段将被忽略。
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue'], // 导入时想要省略的扩展名列表。注意,不 建议忽略自定义导入类型的扩展名(例如:.vue),因为它会干扰 IDE 和类型支持。
},
})