This commit is contained in:
eric
2026-03-11 11:00:37 -05:00
parent 8cbc092437
commit abe5173954
6 changed files with 871 additions and 94 deletions

View File

@@ -4,6 +4,7 @@ import {
mockEbooks, mockEbooks,
mockEbookChapters, mockEbookChapters,
} from '@/data/mockData.js' } from '@/data/mockData.js'
import { ACCESS_LEVEL } from '@/utils/access.js'
export async function fetchEbooks() { export async function fetchEbooks() {
return [...mockEbooks] return [...mockEbooks]
@@ -35,3 +36,22 @@ export async function fetchCourse(courseId) {
const list = await fetchCourses() const list = await fetchCourses()
return list.find((item) => String(item.id) === String(courseId)) || null return list.find((item) => String(item.id) === String(courseId)) || null
} }
/** 获取各课程的首个免费试看章节 */
export async function fetchFreeTrialChapters() {
const courses = await fetchCourses()
const result = []
for (const course of courses) {
const chapters = mockCourseChapters[course.id] || []
const freeChapter = chapters.find((ch) => ch.access_level === ACCESS_LEVEL.FREE)
if (freeChapter) {
result.push({
courseId: course.id,
courseTitle: course.title,
chapterId: freeChapter.id,
chapterTitle: freeChapter.title,
})
}
}
return result
}

View File

@@ -8,3 +8,48 @@ export const NAV_ITEMS = [
{ path: '/', label: '首页' }, { path: '/', label: '首页' },
{ path: '/vip', label: '会员' }, { path: '/vip', label: '会员' },
] ]
/** 网盘配置 - 多个独立资源 */
export const NETDISK_CONFIG = {
enabled: true,
items: [
{ id: 1, name: '大模型', desc: 'GPT、Claude、国产大模型资源包', url: 'https://pan.baidu.com', icon: 'fire-o' },
{ id: 2, name: '懒人安装包', desc: '一键安装,开箱即用', url: 'https://pan.baidu.com', icon: 'down' },
{ id: 3, name: '课程讲义', desc: '配套 PPT、思维导图', url: 'https://pan.baidu.com', icon: 'notes-o' },
{ id: 4, name: '源码合集', desc: '课程示例代码与项目', url: 'https://pan.baidu.com', icon: 'records-o' },
],
}
/** 限时优惠配置 */
export const PROMO_CONFIG = {
enabled: false,
title: '限时优惠',
desc: '报名立享 8 折,仅限本周',
endAt: null, // 如 '2025-03-18 23:59:59' 可显示倒计时
href: '/vip',
}
/** 免费试看配置 */
export const FREE_TRIAL_CONFIG = {
enabled: false,
}
/** 课程预告配置 */
export const COMING_SOON_CONFIG = {
enabled: true,
items: [
{ id: 'soon-1', title: '多模态 AI 实战', desc: '图文理解、语音识别、视频生成', eta: '即将上线' },
{ id: 'soon-2', title: 'AI 副业变现', desc: '从 0 到 1 打造 AI 产品', eta: '筹备中' },
],
}
/** 学习群配置 - 仅展示 QQ 群,微信群隐藏 */
export const STUDY_GROUP_CONFIG = {
enabled: true,
title: '加入学习群',
desc: '与 AI 学习者一起交流,获取最新资料与答疑',
items: [
{ id: 'wechat', name: '微信学习群', icon: 'chat-o', desc: '扫码加入,每日更新', href: '#', type: 'wechat', hidden: true },
{ id: 'qq', name: 'QQ 交流群', icon: 'friends-o', desc: '群号 123456789', href: '#', type: 'qq' },
],
}

View File

@@ -1,24 +1,27 @@
import { getCourseMarkdownRef, getEbookMarkdownRef } from '@/markdown/config.js' import { getCourseMarkdownRef, getEbookMarkdownRef } from '@/markdown/config.js'
import { ACCESS_LEVEL } from '@/utils/access.js' import { ACCESS_LEVEL } from '@/utils/access.js'
export const mockEbooks = [ /** 本地封面占位图SVG data URI含标题文字无网络请求 */
{ function localCover(title, gradient = 0, scale = 1) {
id: 1, const gradients = [
title: 'Prompt 工程实战手册', ['#059669', '#047857'],
cover: 'https://picsum.photos/400/300?random=ai1', ['#0891b2', '#0e7490'],
description: '从零到精通提示词设计、Few-shot、思维链让大模型听懂你的需求。', ['#6366f1', '#4f46e5'],
category_id: 'ai', ['#8b5cf6', '#7c3aed'],
sort_order: 1, ]
}, const [c1, c2] = gradients[gradient % gradients.length]
{ const baseSize = String(title).length > 12 ? 18 : 22
id: 2, const fontSize = Math.round(baseSize * scale)
title: 'AI Agent 开发指南', const safe = String(title).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
cover: 'https://picsum.photos/400/300?random=ai2', const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300"><defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="${c1}"/><stop offset="100%" stop-color="${c2}"/></linearGradient><filter id="shadow"><feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.4"/></filter></defs><rect width="400" height="300" fill="url(#g)"/><text x="200" y="155" text-anchor="middle" fill="rgba(255,255,255,0.95)" font-size="${fontSize}" font-weight="600" font-family="system-ui,-apple-system,sans-serif" filter="url(#shadow)">${safe}</text></svg>`
description: 'RAG、Function Calling、多 Agent 协作,打造可落地的智能体应用。', return 'data:image/svg+xml,' + encodeURIComponent(svg)
category_id: 'ai', }
sort_order: 2,
}, const ebookData = [
{ id: 1, title: 'Prompt 工程实战手册', description: '从零到精通提示词设计、Few-shot、思维链让大模型听懂你的需求。', category_id: 'ai', sort_order: 1 },
{ id: 2, title: 'AI Agent 开发指南', description: 'RAG、Function Calling、多 Agent 协作,打造可落地的智能体应用。', category_id: 'ai', sort_order: 2 },
] ]
export const mockEbooks = ebookData.map((item, i) => ({ ...item, cover: localCover(item.title, i) }))
export const mockEbookChapters = { export const mockEbookChapters = {
1: [ 1: [
@@ -67,24 +70,11 @@ export const mockEbookChapters = {
], ],
} }
export const mockCourses = [ const courseData = [
{ { id: 1, title: 'ChatGPT 与大模型实战', description: 'GPT-4、Claude、国产大模型 API 调用,从 Prompt 到 RAG 全流程实战。', category_id: 'ai', sort_order: 1 },
id: 1, { id: 2, title: 'AI Agent 从 0 到 1', description: 'LangChain、AutoGPT、多 Agent 协作,打造能自主完成任务的智能体。', category_id: 'ai', sort_order: 2 },
title: 'ChatGPT 与大模型实战',
cover: 'https://picsum.photos/400/300?random=ai3',
description: 'GPT-4、Claude、国产大模型 API 调用,从 Prompt 到 RAG 全流程实战。',
category_id: 'ai',
sort_order: 1,
},
{
id: 2,
title: 'AI Agent 从 0 到 1',
cover: 'https://picsum.photos/400/300?random=ai4',
description: 'LangChain、AutoGPT、多 Agent 协作,打造能自主完成任务的智能体。',
category_id: 'ai',
sort_order: 2,
},
] ]
export const mockCourses = courseData.map((item, i) => ({ ...item, cover: localCover(item.title, i, 2) }))
export const mockCourseChapters = { export const mockCourseChapters = {
1: [ 1: [

View File

@@ -11,8 +11,8 @@
<template v-else-if="course"> <template v-else-if="course">
<div class="mainContent"> <div class="mainContent">
<header class="courseHeader"> <header class="courseHeader">
<div class="cover" :class="{ 'cover--empty': !course.cover }" :style="course.cover ? { backgroundImage: `url(${course.cover})` } : {}"> <div class="cover" :class="{ 'cover--empty': !course.cover }">
<van-icon name="play-circle" class="cover__play" /> <img v-if="course.cover" :src="course.cover" alt="" class="cover__img" />
</div> </div>
<h1 class="courseTitle">{{ course.title }}</h1> <h1 class="courseTitle">{{ course.title }}</h1>
<p class="courseDesc">{{ course.description }}</p> <p class="courseDesc">{{ course.description }}</p>
@@ -225,28 +225,39 @@ onMounted(async () => {
padding: @space-sm 0 @space-lg; padding: @space-sm 0 @space-lg;
} }
/* 视频封面 - 参考方糖技能栈 */
.cover { .cover {
width: 100%; width: 100%;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background-size: cover;
background-position: center;
background-color: var(--color-bg-muted); background-color: var(--color-bg-muted);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
position: relative; position: relative;
overflow: hidden; overflow: hidden;
&__img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
&::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.4) 0%, transparent 50%);
pointer-events: none;
}
&.cover--empty { &.cover--empty {
background: linear-gradient(135deg, var(--color-bg-muted) 0%, var(--color-border) 100%); background: linear-gradient(135deg, var(--color-bg-muted) 0%, var(--color-border) 100%);
} }
&__play { &.cover--empty::after {
position: absolute; display: none;
inset: 0;
margin: auto;
font-size: 56px;
color: rgba(255, 255, 255, 0.95);
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
} }
} }

View File

@@ -6,7 +6,9 @@
</div> </div>
<template v-else-if="ebook"> <template v-else-if="ebook">
<div class="coverWrap"> <div class="coverWrap">
<div class="cover" :style="{ backgroundImage: `url(${ebook.cover})` }" /> <div class="cover">
<img v-if="ebook.cover" :src="ebook.cover" alt="" class="cover__img" />
</div>
<p class="desc">{{ ebook.description }}</p> <p class="desc">{{ ebook.description }}</p>
</div> </div>
<div class="chapterList"> <div class="chapterList">
@@ -143,13 +145,22 @@ onMounted(async () => {
aspect-ratio: 3 / 4; aspect-ratio: 3 / 4;
margin: 0 auto 16px; margin: 0 auto 16px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
background-size: cover; background-color: var(--color-bg-muted);
background-position: center;
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
position: relative;
overflow: hidden;
@media (min-width: 768px) { @media (min-width: 768px) {
max-width: 240px; max-width: 240px;
} }
&__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: inherit;
}
} }
.desc { .desc {

View File

@@ -7,15 +7,51 @@
<div class="hero__stats"> <div class="hero__stats">
<span class="hero__stat">{{ filteredCourses.length }} 节课程</span> <span class="hero__stat">{{ filteredCourses.length }} 节课程</span>
<span class="hero__stat">{{ filteredEbooks.length }} 本电子书</span> <span class="hero__stat">{{ filteredEbooks.length }} 本电子书</span>
<span class="hero__stat">{{ netdiskItems.length }} 个网盘资源</span>
</div> </div>
<button class="hero__cta" type="button" @click="$router.push('/vip')">立即报名 </button> <button class="hero__cta" type="button" @click="$router.push('/vip')">立即报名 </button>
</section> </section>
<!-- 限时优惠 Banner -->
<section v-if="PROMO_CONFIG.enabled" class="promoBanner" @click="$router.push(PROMO_CONFIG.href)">
<div class="promoBanner__inner">
<span class="promoBanner__tag">限时</span>
<div class="promoBanner__text">
<strong>{{ PROMO_CONFIG.title }}</strong>
<span>{{ PROMO_CONFIG.desc }}</span>
</div>
<van-icon name="arrow" class="promoBanner__arrow" />
</div>
</section>
<!-- 免费试看 -->
<section v-if="FREE_TRIAL_CONFIG.enabled && freeTrials.length" class="freeTrial">
<h2 class="freeTrial__title">免费试看</h2>
<p class="freeTrial__desc">无需报名立即体验</p>
<div class="freeTrial__list">
<router-link
v-for="t in freeTrials"
:key="`${t.courseId}-${t.chapterId}`"
:to="`/course/${t.courseId}/chapter/${t.chapterId}`"
class="freeTrial__item"
>
<span class="freeTrial__course">{{ t.courseTitle }}</span>
<span class="freeTrial__chapter">{{ t.chapterTitle }}</span>
<van-icon name="play-circle-o" size="20" />
</router-link>
</div>
</section>
<!-- 重点推荐课程模块首页核心 --> <!-- 重点推荐课程模块首页核心 -->
<main class="main catalog catalog--featured" ref="catalogRef"> <main class="main catalog catalog--featured" ref="catalogRef">
<section v-if="loading" class="catalog__skeleton"> <section v-if="loading" class="catalog__skeleton">
<van-skeleton title :row="4" /> <div class="skeletonGrid skeletonGrid--course">
<van-skeleton title :row="4" class="skeletonSecond" /> <div v-for="i in 6" :key="i" class="skeletonCard skeletonCard--course">
<div class="skeletonCard__cover" />
<div class="skeletonCard__line skeletonCard__line--title" />
<div class="skeletonCard__line skeletonCard__line--desc" />
</div>
</div>
</section> </section>
<template v-else> <template v-else>
@@ -26,19 +62,22 @@
<van-tab title="课程" :badge="filteredCourses.length || ''"> <van-tab title="课程" :badge="filteredCourses.length || ''">
<div class="catalog__content"> <div class="catalog__content">
<van-empty v-if="!filteredCourses.length" description="暂无课程" class="catalog__empty" /> <van-empty v-if="!filteredCourses.length" description="暂无课程" class="catalog__empty" />
<div v-else class="courseGrid"> <template v-else>
<div class="catalogGrid catalogGrid--course">
<article <article
v-for="(item, i) in filteredCourses" v-for="(item, i) in displayedCourses"
:key="item.id" :key="item.id"
class="courseCard courseCard--featured animate__animated animate__fadeInUp" class="courseCard courseCard--featured animate__animated animate__fadeInUp"
:style="{ animationDelay: `${i * 0.05}s` }" :style="{ animationDelay: `${Math.min(i, 15) * 0.03}s` }"
@click="$router.push(`/course/${item.id}`)" @click="$router.push(`/course/${item.id}`)"
> >
<div class="courseCard__cover"> <div class="courseCard__cover">
<img v-if="!item._coverError" :src="item.cover" alt="" @error="item._coverError = true" loading="lazy" /> <img v-if="!item._coverError" :src="item.cover" alt="" @error="item._coverError = true" loading="lazy" decoding="async" />
<van-icon v-else name="photo-fail" size="28" class="coverFallback" /> <van-icon v-else name="photo-fail" size="28" class="coverFallback" />
<div class="courseCard__play"> <div class="courseCard__play">
<van-icon name="play-circle-o" size="48" /> <span class="courseCard__playIcon">
<van-icon name="play" size="24" />
</span>
</div> </div>
</div> </div>
<div class="courseCard__body"> <div class="courseCard__body">
@@ -47,21 +86,26 @@
</div> </div>
</article> </article>
</div> </div>
<div v-if="hasMoreCourses" class="catalog__loadMore">
<button type="button" class="loadMoreBtn" @click="loadMoreCourses">加载更多</button>
</div>
</template>
</div> </div>
</van-tab> </van-tab>
<van-tab title="电子书" :badge="filteredEbooks.length || ''"> <van-tab title="电子书" :badge="filteredEbooks.length || ''">
<div class="catalog__content"> <div class="catalog__content">
<van-empty v-if="!filteredEbooks.length" description="暂无电子书" class="catalog__empty" /> <van-empty v-if="!filteredEbooks.length" description="暂无电子书" class="catalog__empty" />
<div v-else class="ebookGrid"> <template v-else>
<div class="catalogGrid catalogGrid--ebook">
<article <article
v-for="(item, i) in filteredEbooks" v-for="(item, i) in displayedEbooks"
:key="item.id" :key="item.id"
class="ebookCard animate__animated animate__fadeInUp" class="ebookCard animate__animated animate__fadeInUp"
:style="{ animationDelay: `${i * 0.05}s` }" :style="{ animationDelay: `${Math.min(i, 15) * 0.03}s` }"
@click="$router.push(`/ebook/${item.id}`)" @click="$router.push(`/ebook/${item.id}`)"
> >
<div class="ebookCard__cover"> <div class="ebookCard__cover">
<img v-if="!item._coverError" :src="item.cover" alt="" @error="item._coverError = true" loading="lazy" /> <img v-if="!item._coverError" :src="item.cover" alt="" @error="item._coverError = true" loading="lazy" decoding="async" />
<van-icon v-else name="photo-fail" size="28" class="coverFallback" /> <van-icon v-else name="photo-fail" size="28" class="coverFallback" />
<span class="ebookCard__tag">电子书</span> <span class="ebookCard__tag">电子书</span>
</div> </div>
@@ -71,6 +115,38 @@
</div> </div>
</article> </article>
</div> </div>
<div v-if="hasMoreEbooks" class="catalog__loadMore">
<button type="button" class="loadMoreBtn" @click="loadMoreEbooks">加载更多</button>
</div>
</template>
</div>
</van-tab>
<van-tab title="网盘" :badge="netdiskItems.length || ''">
<div class="catalog__content">
<van-empty v-if="!netdiskItems.length" description="暂无网盘资料" class="catalog__empty" />
<template v-else>
<div class="catalogGrid catalogGrid--netdisk">
<a
v-for="(item, i) in displayedNetdisk"
:key="item.id"
:href="item.url"
target="_blank"
rel="noopener noreferrer"
class="netdiskCard animate__animated animate__fadeInUp"
:style="{ animationDelay: `${Math.min(i, 15) * 0.03}s` }"
>
<div class="netdiskCard__icon">
<van-icon :name="item.icon || 'down'" size="24" />
</div>
<h3 class="netdiskCard__title">{{ item.name }}</h3>
<p class="netdiskCard__desc">{{ item.desc }}</p>
<span class="netdiskCard__action">前往下载 <van-icon name="arrow" size="12" /></span>
</a>
</div>
<div v-if="hasMoreNetdisk" class="catalog__loadMore">
<button type="button" class="loadMoreBtn" @click="loadMoreNetdisk">加载更多</button>
</div>
</template>
</div> </div>
</van-tab> </van-tab>
</van-tabs> </van-tabs>
@@ -78,6 +154,45 @@
</template> </template>
</main> </main>
<!-- 学习群模块仅展示 QQ -->
<section v-if="STUDY_GROUP_CONFIG.enabled && visibleStudyGroups.length" class="studyGroup">
<div class="studyGroup__inner">
<h2 class="studyGroup__title">{{ STUDY_GROUP_CONFIG.title }}</h2>
<p class="studyGroup__desc">{{ STUDY_GROUP_CONFIG.desc }}</p>
<div class="studyGroup__grid">
<a
v-for="item in visibleStudyGroups"
:key="item.id"
:href="item.href"
target="_blank"
rel="noopener noreferrer"
class="studyGroup__card"
:class="`studyGroup__card--${item.type}`"
>
<div class="studyGroup__cardIcon">
<van-icon :name="item.icon" size="28" />
</div>
<h3 class="studyGroup__cardTitle">{{ item.name }}</h3>
<p class="studyGroup__cardDesc">{{ item.desc }}</p>
<span class="studyGroup__cardAction">加入 <van-icon name="arrow" size="14" /></span>
</a>
</div>
</div>
</section>
<!-- 课程预告 -->
<section v-if="COMING_SOON_CONFIG.enabled && COMING_SOON_CONFIG.items?.length" class="comingSoon">
<h2 class="comingSoon__title">课程预告</h2>
<p class="comingSoon__desc">更多实战课程敬请期待</p>
<div class="comingSoon__grid">
<div v-for="item in COMING_SOON_CONFIG.items" :key="item.id" class="comingSoon__card">
<span class="comingSoon__eta">{{ item.eta }}</span>
<h3 class="comingSoon__titleCard">{{ item.title }}</h3>
<p class="comingSoon__descCard">{{ item.desc }}</p>
</div>
</div>
</section>
<!-- 次要介绍弱于课程但保留完整内容 --> <!-- 次要介绍弱于课程但保留完整内容 -->
<section class="audience audience--secondary"> <section class="audience audience--secondary">
<h2 class="audience__title">适合谁学习</h2> <h2 class="audience__title">适合谁学习</h2>
@@ -151,23 +266,82 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { showFailToast } from 'vant' import { showFailToast } from 'vant'
import { fetchCourses, fetchEbooks } from '@/composables/useKnowledge.js' import { fetchCourses, fetchEbooks } from '@/composables/useKnowledge.js'
import { mockCourses, mockEbooks } from '@/data/mockData.js' import { mockCourses, mockEbooks } from '@/data/mockData.js'
import { NETDISK_CONFIG, PROMO_CONFIG, FREE_TRIAL_CONFIG, COMING_SOON_CONFIG, STUDY_GROUP_CONFIG } from '@/config/app.js'
import { fetchFreeTrialChapters } from '@/composables/useKnowledge.js'
const PAGE_SIZE = 12
const ebooks = ref([]) const ebooks = ref([])
const courses = ref([]) const courses = ref([])
const freeTrials = ref([])
const loading = ref(true) const loading = ref(true)
const catalogRef = ref(null) const catalogRef = ref(null)
const activeTab = ref(0) const activeTab = ref(0)
const coursePage = ref(1)
const ebookPage = ref(1)
const netdiskPage = ref(1)
const filteredEbooks = computed(() => ebooks.value) const filteredEbooks = computed(() => ebooks.value)
const filteredCourses = computed(() => courses.value) const filteredCourses = computed(() => courses.value)
function onTabChange() { const netdiskItems = computed(() => (NETDISK_CONFIG.enabled && NETDISK_CONFIG.items) ? NETDISK_CONFIG.items : [])
// 切换 tab 时可选滚动或其它逻辑
const visibleStudyGroups = computed(() =>
(STUDY_GROUP_CONFIG.items || []).filter((item) => !item.hidden && item.type === 'qq')
)
const displayedCourses = computed(() =>
filteredCourses.value.slice(0, PAGE_SIZE * coursePage.value)
)
const displayedEbooks = computed(() =>
filteredEbooks.value.slice(0, PAGE_SIZE * ebookPage.value)
)
const displayedNetdisk = computed(() =>
netdiskItems.value.slice(0, PAGE_SIZE * netdiskPage.value)
)
const hasMoreCourses = computed(() => displayedCourses.value.length < filteredCourses.value.length)
const hasMoreEbooks = computed(() => displayedEbooks.value.length < filteredEbooks.value.length)
const hasMoreNetdisk = computed(() => displayedNetdisk.value.length < netdiskItems.value.length)
function loadMoreCourses() {
coursePage.value += 1
} }
function loadMoreEbooks() {
ebookPage.value += 1
}
function loadMoreNetdisk() {
netdiskPage.value += 1
}
function onTabChange() {
coursePage.value = 1
ebookPage.value = 1
netdiskPage.value = 1
}
// 滚动到底自动加载更多(虚拟滚动体验)
let loadMoreObserver = null
function setupLoadMoreObserver() {
loadMoreObserver?.disconnect()
loadMoreObserver = new IntersectionObserver(
(entries) => {
if (!entries.some((e) => e.isIntersecting)) return
if (activeTab.value === 0 && hasMoreCourses.value) loadMoreCourses()
else if (activeTab.value === 1 && hasMoreEbooks.value) loadMoreEbooks()
else if (activeTab.value === 2 && hasMoreNetdisk.value) loadMoreNetdisk()
},
{ rootMargin: '120px', threshold: 0 }
)
document.querySelectorAll('.catalog__loadMore').forEach((el) => loadMoreObserver.observe(el))
}
watch([coursePage, ebookPage, netdiskPage, activeTab], () => {
setTimeout(setupLoadMoreObserver, 150)
}, { flush: 'post' })
function scrollToCatalog() { function scrollToCatalog() {
catalogRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' }) catalogRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
@@ -175,19 +349,31 @@ function scrollToCatalog() {
onMounted(async () => { onMounted(async () => {
loading.value = true loading.value = true
setTimeout(setupLoadMoreObserver, 500)
try { try {
const [ebookList, courseList] = await Promise.all([fetchEbooks(), fetchCourses()]) const [ebookList, courseList, trialList] = await Promise.all([
fetchEbooks(),
fetchCourses(),
fetchFreeTrialChapters(),
])
ebooks.value = (ebookList || []).map((item) => ({ ...item, _coverError: false })) ebooks.value = (ebookList || []).map((item) => ({ ...item, _coverError: false }))
courses.value = (courseList || []).map((item) => ({ ...item, _coverError: false })) courses.value = (courseList || []).map((item) => ({ ...item, _coverError: false }))
freeTrials.value = trialList || []
} catch { } catch {
showFailToast('加载失败,已切换为本地数据') showFailToast('加载失败,已切换为本地数据')
ebooks.value = (mockEbooks || []).map((item) => ({ ...item, _coverError: false })) ebooks.value = (mockEbooks || []).map((item) => ({ ...item, _coverError: false }))
courses.value = (mockCourses || []).map((item) => ({ ...item, _coverError: false })) courses.value = (mockCourses || []).map((item) => ({ ...item, _coverError: false }))
freeTrials.value = []
} finally { } finally {
loading.value = false loading.value = false
setTimeout(setupLoadMoreObserver, 200)
} }
}) })
onUnmounted(() => {
loadMoreObserver?.disconnect()
})
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
@@ -244,6 +430,181 @@ onMounted(async () => {
transform: translateY(-1px); transform: translateY(-1px);
} }
/* 限时优惠 Banner */
.promoBanner {
margin: 0 var(--page-padding) 24px;
padding: 14px 18px;
background: linear-gradient(135deg, rgba(5, 150, 105, 0.15) 0%, rgba(5, 150, 105, 0.06) 100%);
border: 1px solid rgba(5, 150, 105, 0.3);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s;
}
.promoBanner:hover {
border-color: var(--color-primary);
box-shadow: 0 4px 12px rgba(5, 150, 105, 0.15);
}
.promoBanner__inner {
display: flex;
align-items: center;
gap: 12px;
}
.promoBanner__tag {
padding: 2px 8px;
font-size: 12px;
font-weight: 700;
color: var(--color-primary);
background: rgba(5, 150, 105, 0.2);
border-radius: 4px;
}
.promoBanner__text {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
strong {
font-size: 15px;
color: var(--color-text);
}
span {
font-size: 12px;
color: var(--color-text-secondary);
}
}
.promoBanner__arrow {
color: var(--color-primary);
font-size: 18px;
}
/* 免费试看 */
.freeTrial {
padding: 24px var(--page-padding) 32px;
background: var(--color-bg-muted);
}
.freeTrial__title {
margin: 0 0 4px;
font-size: 18px;
font-weight: 600;
color: var(--color-text);
}
.freeTrial__desc {
margin: 0 0 16px;
font-size: 13px;
color: var(--color-text-secondary);
}
.freeTrial__list {
display: flex;
flex-direction: column;
gap: 10px;
}
.freeTrial__item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
text-decoration: none;
color: inherit;
transition: all 0.2s;
}
.freeTrial__item:hover {
border-color: var(--color-primary);
}
.freeTrial__course {
font-size: 14px;
font-weight: 600;
color: var(--color-text);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.freeTrial__chapter {
font-size: 12px;
color: var(--color-text-secondary);
flex-shrink: 0;
}
.freeTrial__item .van-icon {
color: var(--color-primary);
flex-shrink: 0;
}
/* 课程预告 */
.comingSoon {
padding: 32px var(--page-padding) 40px;
max-width: var(--page-max-width);
margin: 0 auto;
}
.comingSoon__title {
margin: 0 0 4px;
font-size: 18px;
font-weight: 600;
color: var(--color-text-secondary);
text-align: center;
}
.comingSoon__desc {
margin: 0 0 20px;
font-size: 13px;
color: var(--color-text-muted);
text-align: center;
}
.comingSoon__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.comingSoon__card {
padding: 20px 16px;
background: var(--color-bg-muted);
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
}
.comingSoon__eta {
display: inline-block;
margin-bottom: 8px;
font-size: 11px;
font-weight: 600;
color: var(--color-text-muted);
letter-spacing: 0.05em;
}
.comingSoon__titleCard {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
color: var(--color-text);
}
.comingSoon__descCard {
margin: 0;
font-size: 12px;
color: var(--color-text-secondary);
line-height: 1.5;
}
/* 适合谁 - 次要层级,弱于课程 */ /* 适合谁 - 次要层级,弱于课程 */
.audience--secondary { .audience--secondary {
padding: 32px var(--page-padding) 36px; padding: 32px var(--page-padding) 36px;
@@ -438,13 +799,80 @@ onMounted(async () => {
padding: 48px 0; padding: 48px 0;
} }
/* 电子书 - 竖版卡片 */ .catalog__loadMore {
.ebookGrid { margin-top: 24px;
text-align: center;
}
.loadMoreBtn {
padding: 10px 32px;
font-size: 14px;
font-weight: 500;
color: var(--color-primary);
background: transparent;
border: 1px solid var(--color-primary);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.loadMoreBtn:hover {
background: rgba(5, 150, 105, 0.08);
}
/* 动态网格 - 适配 20+ 资源 */
.catalogGrid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 20px; gap: 20px;
} }
.catalogGrid--course {
grid-template-columns: 1fr;
gap: 16px;
}
.catalogGrid--ebook {
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.catalogGrid--netdisk {
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
@media (min-width: 600px) {
.catalogGrid--course {
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.catalogGrid--ebook {
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.catalogGrid--netdisk {
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
}
@media (min-width: 960px) {
.catalogGrid--course {
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.catalogGrid--ebook {
grid-template-columns: repeat(4, 1fr);
gap: 24px;
}
.catalogGrid--netdisk {
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
}
/* 电子书 - 竖版卡片 */
.ebookCard { .ebookCard {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -526,11 +954,6 @@ onMounted(async () => {
} }
/* 课程 - 横版卡片(重点推荐) */ /* 课程 - 横版卡片(重点推荐) */
.courseGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 24px;
}
.courseCard--featured { .courseCard--featured {
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
@@ -566,6 +989,7 @@ onMounted(async () => {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
} }
/* 视频封面 - 方糖技能栈风格:渐变遮罩 + 圆形播放按钮 */
.courseCard__cover { .courseCard__cover {
position: relative; position: relative;
width: 100%; width: 100%;
@@ -573,29 +997,57 @@ onMounted(async () => {
overflow: hidden; overflow: hidden;
background: var(--color-bg-muted); background: var(--color-bg-muted);
&::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.55) 0%, transparent 45%);
z-index: 1;
pointer-events: none;
}
img { img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
display: block; display: block;
transition: transform 0.35s ease;
} }
} }
.courseCard:hover .courseCard__cover img {
transform: scale(1.06);
}
.courseCard__play { .courseCard__play {
position: absolute; position: absolute;
inset: 0; inset: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 48px; z-index: 2;
color: rgba(255, 255, 255, 0.95); pointer-events: none;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
background: rgba(0, 0, 0, 0.15);
transition: background 0.2s;
} }
.courseCard:hover .courseCard__play { .courseCard__playIcon {
background: rgba(0, 0, 0, 0.25); width: 58px;
height: 58px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: rgba(0, 0, 0, 0.45);
border-radius: 50%;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
transition: all 0.25s ease;
}
.courseCard:hover .courseCard__playIcon {
background: rgba(0, 0, 0, 0.55);
transform: scale(1.1);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
} }
.courseCard__body { .courseCard__body {
@@ -629,14 +1081,241 @@ onMounted(async () => {
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.catalog__skeleton { /* 网盘 - 独立资源卡片 */
padding: 24px var(--page-padding); .netdiskCard {
display: flex;
flex-direction: column;
padding: 20px 16px;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
text-decoration: none;
transition: all 0.2s;
box-shadow: var(--shadow-sm);
}
.skeletonSecond { .netdiskCard:hover {
margin-top: 18px; border-color: var(--color-primary);
box-shadow: 0 8px 20px rgba(5, 150, 105, 0.12);
transform: translateY(-2px);
}
.netdiskCard__icon {
width: 44px;
height: 44px;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(5, 150, 105, 0.1);
color: var(--color-primary);
border-radius: var(--radius-sm);
}
.netdiskCard__title {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
color: var(--color-text);
line-height: 1.4;
}
.netdiskCard__desc {
margin: 0 0 8px;
font-size: 12px;
color: var(--color-text-secondary);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.netdiskCard__action {
margin-top: auto;
font-size: 13px;
font-weight: 500;
color: var(--color-primary);
display: inline-flex;
align-items: center;
gap: 4px;
}
/* 学习群模块 */
.studyGroup {
padding: 40px var(--page-padding) 48px;
background: linear-gradient(180deg, var(--color-bg-muted) 0%, var(--color-bg) 100%);
}
.studyGroup__inner {
max-width: var(--page-max-width);
margin: 0 auto;
}
.studyGroup__title {
margin: 0 0 8px;
font-size: 22px;
font-weight: 700;
color: var(--color-text);
text-align: center;
letter-spacing: -0.02em;
}
.studyGroup__desc {
margin: 0 0 28px;
font-size: 15px;
color: var(--color-text-secondary);
text-align: center;
line-height: 1.6;
}
.studyGroup__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 20px;
}
.studyGroup__card {
display: flex;
flex-direction: column;
align-items: center;
padding: 28px 24px;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
text-decoration: none;
transition: all 0.25s ease;
box-shadow: var(--shadow-sm);
}
.studyGroup__card:hover {
transform: translateY(-4px);
border-color: var(--color-primary);
box-shadow: 0 12px 24px rgba(5, 150, 105, 0.12);
}
.studyGroup__card--wechat:hover {
border-color: #07c160;
box-shadow: 0 12px 24px rgba(7, 193, 96, 0.15);
}
.studyGroup__card--qq:hover {
border-color: #12b7f5;
box-shadow: 0 12px 24px rgba(18, 183, 245, 0.15);
}
.studyGroup__cardIcon {
width: 56px;
height: 56px;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-muted);
color: var(--color-primary);
border-radius: 50%;
transition: all 0.25s;
}
.studyGroup__card--wechat .studyGroup__cardIcon {
background: rgba(7, 193, 96, 0.12);
color: #07c160;
}
.studyGroup__card--qq .studyGroup__cardIcon {
background: rgba(18, 183, 245, 0.12);
color: #12b7f5;
}
.studyGroup__cardTitle {
margin: 0 0 6px;
font-size: 17px;
font-weight: 600;
color: var(--color-text);
}
.studyGroup__cardDesc {
margin: 0 0 4px;
font-size: 13px;
color: var(--color-text-secondary);
line-height: 1.5;
}
.studyGroup__cardAction {
margin-top: 8px;
font-size: 14px;
font-weight: 500;
color: var(--color-primary);
display: inline-flex;
align-items: center;
gap: 2px;
}
/* 骨架屏 - 匹配课程卡片布局 */
.catalog__skeleton {
padding: 0 var(--page-padding) 32px;
}
.skeletonGrid {
display: grid;
gap: 20px;
}
.skeletonGrid--course {
grid-template-columns: 1fr;
}
@media (min-width: 600px) {
.skeletonGrid--course {
grid-template-columns: repeat(2, 1fr);
} }
} }
@media (min-width: 960px) {
.skeletonGrid--course {
grid-template-columns: repeat(3, 1fr);
}
}
.skeletonCard {
background: var(--color-bg-elevated);
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--color-border);
}
.skeletonCard--course .skeletonCard__cover {
aspect-ratio: 16 / 9;
background: linear-gradient(90deg, var(--color-bg-muted) 25%, var(--color-border) 50%, var(--color-bg-muted) 75%);
background-size: 200% 100%;
animation: skeletonShimmer 1.5s ease-in-out infinite;
}
.skeletonCard__line {
height: 14px;
margin: 12px 16px 0;
background: linear-gradient(90deg, var(--color-bg-muted) 25%, var(--color-border) 50%, var(--color-bg-muted) 75%);
background-size: 200% 100%;
animation: skeletonShimmer 1.5s ease-in-out infinite;
border-radius: 4px;
}
.skeletonCard__line--title {
width: 70%;
}
.skeletonCard__line--desc {
width: 100%;
margin-top: 8px;
margin-bottom: 16px;
height: 12px;
}
@keyframes skeletonShimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (max-width: 767px) { @media (max-width: 767px) {
.hero--compact { .hero--compact {
padding: 24px 20px 20px; padding: 24px 20px 20px;
@@ -674,11 +1353,16 @@ onMounted(async () => {
font-size: 12px; font-size: 12px;
} }
.ebookGrid { .catalogGrid--ebook {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 16px; gap: 16px;
} }
.catalogGrid--netdisk {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.ebookCard__body { .ebookCard__body {
padding: 10px 2px 0; padding: 10px 2px 0;
} }
@@ -687,9 +1371,25 @@ onMounted(async () => {
font-size: 13px; font-size: 13px;
} }
.courseGrid { .catalogGrid--course {
gap: 16px;
}
.studyGroup {
padding: 32px var(--page-padding) 40px;
}
.studyGroup__title {
font-size: 20px;
}
.studyGroup__grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 16px; gap: 16px;
} }
.studyGroup__card {
padding: 24px 20px;
}
} }
</style> </style>