1588 lines
45 KiB
Vue
1588 lines
45 KiB
Vue
<template>
|
||
<div class="home-container">
|
||
<!-- 顶部 Banner -->
|
||
<div class="home-height">
|
||
<div class="home-banner">
|
||
<div class="neon-sun-wrapper">
|
||
<div class="neon-sun"></div>
|
||
<!-- Solar Flares -->
|
||
<div class="solar-flare" style="--angle: -25deg; --delay: 0s"></div>
|
||
<div class="solar-flare" style="--angle: -15deg; --delay: 2.1s"></div>
|
||
<div class="solar-flare" style="--angle: -5deg; --delay: 1.3s"></div>
|
||
<div class="solar-flare" style="--angle: 5deg; --delay: 3.5s"></div>
|
||
<div class="solar-flare" style="--angle: 18deg; --delay: 0.8s"></div>
|
||
<div class="solar-flare" style="--angle: 28deg; --delay: 2.9s"></div>
|
||
</div>
|
||
|
||
<!-- Tech Elements -->
|
||
<div class="tech-grid"></div>
|
||
|
||
<InteractiveTechBackground />
|
||
|
||
<div class="watermark">51AIapi</div>
|
||
<div class="home-banner-content">
|
||
<div class="main-title">一句话 做AI</div>
|
||
<div class="sub-title">该网站为测试网站,本网站仅供学习参考</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- New Homepage Sections -->
|
||
<div class="home-section-container">
|
||
<!-- 首页广场 -->
|
||
<HomePlaza
|
||
v-if="plazaArticles.length"
|
||
:articles="plazaArticles"
|
||
:is-logged-in="isLoggedIn"
|
||
@like-changed="syncLikeState"
|
||
/>
|
||
<div v-else-if="loadingHomeFeatured" class="loading-card">
|
||
首页推送加载中...
|
||
</div>
|
||
|
||
<!-- 更多精选文章 -->
|
||
<div v-if="moreFeaturedArticles.length > 0" class="more-featured-section">
|
||
<div class="section-header">
|
||
<div class="section-title">更多精选文章</div>
|
||
</div>
|
||
<div class="cards-grid-responsive">
|
||
<MarketCard
|
||
v-for="article in moreFeaturedArticles"
|
||
:key="article.slug"
|
||
:slug="article.slug"
|
||
:title="article.title"
|
||
:cover="article.cover || defaultCover"
|
||
:tags="article.tagList"
|
||
:owner-name="article.author?.username"
|
||
:owner-avatar="article.author?.image || undefined"
|
||
:views="article.views"
|
||
:likes="article.favoritesCount || 0"
|
||
:favorited="article.favorited"
|
||
:detail-href="`/articles/${article.slug}`"
|
||
:created-at="article.createdAt"
|
||
@toggle-like="toggleLike(article)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 按标签浏览 -->
|
||
<div v-if="showTagsSection" class="tags-section">
|
||
<div class="section-header">
|
||
<div class="section-title">按标签浏览</div>
|
||
</div>
|
||
|
||
<div class="tags-tabs">
|
||
<button
|
||
v-for="tag in allTags"
|
||
:key="tag"
|
||
class="tag-pill"
|
||
:class="{ active: activeTag === tag }"
|
||
@click="activeTag = tag"
|
||
>
|
||
{{ tag }}
|
||
</button>
|
||
</div>
|
||
|
||
<div class="cards-grid-responsive">
|
||
<MarketCard
|
||
v-for="article in filteredByTag"
|
||
:key="article.slug"
|
||
:slug="article.slug"
|
||
:title="article.title"
|
||
:cover="article.cover || defaultCover"
|
||
:tags="article.tagList"
|
||
:owner-name="article.author?.username"
|
||
:owner-avatar="article.author?.image || undefined"
|
||
:views="article.views"
|
||
:likes="article.favoritesCount || 0"
|
||
:favorited="article.favorited"
|
||
:detail-href="`/articles/${article.slug}`"
|
||
:created-at="article.createdAt"
|
||
@toggle-like="toggleLike(article)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="loadingTagArticles" class="tags-section">
|
||
<div class="section-header">
|
||
<div class="section-title">按标签浏览</div>
|
||
</div>
|
||
<div class="empty">文章加载中...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Admin Panel -->
|
||
<div v-if="showAdminPanel" class="admin-panel-container">
|
||
<div class="admin-stats-grid">
|
||
<div class="admin-stat-card">
|
||
<div class="admin-stat-label">用户总数</div>
|
||
<div class="admin-stat-value">{{ adminStats?.users ?? 0 }}</div>
|
||
</div>
|
||
<div class="admin-stat-card">
|
||
<div class="admin-stat-label">角色数</div>
|
||
<div class="admin-stat-value">{{ adminStats?.roles ?? 0 }}</div>
|
||
</div>
|
||
<div class="admin-stat-card">
|
||
<div class="admin-stat-label">文章总数</div>
|
||
<div class="admin-stat-value">{{ adminStats?.articles ?? 0 }}</div>
|
||
</div>
|
||
<div class="admin-stat-card">
|
||
<div class="admin-stat-label">今日新增</div>
|
||
<div class="admin-stat-value">{{ adminStats?.published_today ?? 0 }}</div>
|
||
</div>
|
||
<div class="admin-stat-card">
|
||
<div class="admin-stat-label">总浏览</div>
|
||
<div class="admin-stat-value">{{ adminStats?.total_views ?? 0 }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="admin-tabs">
|
||
<button
|
||
v-for="tab in adminTabs"
|
||
:key="tab.value"
|
||
type="button"
|
||
class="admin-tab"
|
||
:class="{ active: adminActiveTab === tab.value }"
|
||
@click="adminActiveTab = tab.value"
|
||
>
|
||
{{ tab.label }}
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="adminActiveTab === 'users'" class="admin-section">
|
||
<div class="admin-section-header">
|
||
<div>
|
||
<div class="admin-section-title">用户管理</div>
|
||
<div class="admin-section-sub">
|
||
共 {{ adminUsersTotal }} 人
|
||
</div>
|
||
</div>
|
||
<div class="admin-filter-row">
|
||
<input
|
||
v-model="adminFilters.userSearch"
|
||
class="admin-input"
|
||
type="text"
|
||
placeholder="搜索用户名 / 邮箱"
|
||
/>
|
||
<select
|
||
v-model="adminFilters.userRoleId"
|
||
class="admin-input"
|
||
>
|
||
<option value="">全部角色</option>
|
||
<option
|
||
v-for="role in adminRoles"
|
||
:key="role.id"
|
||
:value="role.id"
|
||
>
|
||
{{ role.name }}
|
||
</option>
|
||
</select>
|
||
<button type="button" class="admin-btn" @click="loadAdminUsers">
|
||
查询
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="admin-two-columns">
|
||
<div class="admin-card">
|
||
<div class="admin-card-title">新建用户</div>
|
||
<form class="admin-form" @submit.prevent="submitNewUser">
|
||
<label>用户名</label>
|
||
<input v-model="newUserForm.username" class="admin-input" type="text" required />
|
||
|
||
<label>邮箱</label>
|
||
<input v-model="newUserForm.email" class="admin-input" type="email" required />
|
||
|
||
<label>初始密码</label>
|
||
<input v-model="newUserForm.password" class="admin-input" type="password" required />
|
||
|
||
<label>简介</label>
|
||
<textarea v-model="newUserForm.bio" class="admin-input" rows="2" />
|
||
|
||
<label>绑定角色</label>
|
||
<div class="admin-role-checkboxes">
|
||
<label v-for="role in adminRoles" :key="role.id">
|
||
<input
|
||
type="checkbox"
|
||
:value="role.id"
|
||
v-model="newUserForm.roleIds"
|
||
/>
|
||
{{ role.name }}
|
||
</label>
|
||
</div>
|
||
|
||
<button
|
||
class="admin-btn primary"
|
||
type="submit"
|
||
:disabled="adminLoading.createUser"
|
||
>
|
||
{{ adminLoading.createUser ? '创建中...' : '创建用户' }}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="admin-card admin-table-card">
|
||
<div class="admin-card-title">全部用户</div>
|
||
<div v-if="adminLoading.users" class="admin-empty">加载中...</div>
|
||
<div v-else-if="adminUsers.length === 0" class="admin-empty">暂无用户</div>
|
||
<div v-else class="admin-table-wrapper">
|
||
<table class="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th>用户</th>
|
||
<th>邮箱</th>
|
||
<th>角色</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="user in adminUsers" :key="user.id">
|
||
<td>
|
||
<template v-if="editingUser?.id === user.id">
|
||
<input
|
||
v-model="editingUser.username"
|
||
class="admin-input"
|
||
type="text"
|
||
/>
|
||
</template>
|
||
<template v-else>
|
||
<div class="admin-user-name">{{ user.username }}</div>
|
||
<div class="admin-user-meta">
|
||
创建于:{{ formatDate(user.created_at) }}
|
||
</div>
|
||
</template>
|
||
</td>
|
||
<td>
|
||
<template v-if="editingUser?.id === user.id">
|
||
<input
|
||
v-model="editingUser.email"
|
||
class="admin-input"
|
||
type="email"
|
||
/>
|
||
</template>
|
||
<template v-else>
|
||
{{ user.email }}
|
||
</template>
|
||
</td>
|
||
<td>
|
||
<template v-if="editingUser?.id === user.id">
|
||
<div class="admin-role-checkboxes">
|
||
<label
|
||
v-for="role in adminRoles"
|
||
:key="role.id"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
:value="role.id"
|
||
v-model="editingUser.roleIds"
|
||
/>
|
||
{{ role.name }}
|
||
</label>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="admin-role-tags">
|
||
<span v-for="role in user.roles" :key="role.id">
|
||
{{ role.name }}
|
||
</span>
|
||
<span v-if="!user.roles.length">--</span>
|
||
</div>
|
||
</template>
|
||
</td>
|
||
<td class="admin-actions">
|
||
<template v-if="editingUser?.id === user.id">
|
||
<button type="button" class="admin-btn primary" @click="saveUserEdit">
|
||
保存
|
||
</button>
|
||
<button type="button" class="admin-btn" @click="cancelUserEdit">
|
||
取消
|
||
</button>
|
||
</template>
|
||
<template v-else>
|
||
<button type="button" class="admin-btn primary" @click="startUserEdit(user)">
|
||
编辑
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="admin-btn danger"
|
||
@click="deleteAdminUser(user)"
|
||
>
|
||
删除
|
||
</button>
|
||
</template>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="adminActiveTab === 'roles'" class="admin-section">
|
||
<div class="admin-two-columns">
|
||
<div class="admin-card">
|
||
<div class="admin-card-title">
|
||
{{ editingRole.id ? '编辑角色' : '新建角色' }}
|
||
</div>
|
||
<form class="admin-form" @submit.prevent="submitRoleForm">
|
||
<label>角色名</label>
|
||
<input v-model="editingRole.name" class="admin-input" type="text" required />
|
||
|
||
<label>描述</label>
|
||
<textarea v-model="editingRole.description" class="admin-input" rows="2" />
|
||
|
||
<label>权限(用逗号隔开)</label>
|
||
<input
|
||
v-model="editingRole.permissionsInput"
|
||
class="admin-input"
|
||
type="text"
|
||
placeholder="如:articles:write, users:read"
|
||
/>
|
||
|
||
<div class="admin-form-buttons">
|
||
<button class="admin-btn primary" type="submit" :disabled="adminLoading.saveRole">
|
||
{{ editingRole.id ? '保存修改' : '创建角色' }}
|
||
</button>
|
||
<button
|
||
v-if="editingRole.id"
|
||
type="button"
|
||
class="admin-btn"
|
||
@click="resetRoleForm"
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="admin-card admin-table-card">
|
||
<div class="admin-card-title">角色列表</div>
|
||
<div v-if="adminLoading.roles" class="admin-empty">加载中...</div>
|
||
<div v-else-if="adminRoles.length === 0" class="admin-empty">暂无角色</div>
|
||
<ul v-else class="admin-role-list">
|
||
<li v-for="role in adminRoles" :key="role.id">
|
||
<div>
|
||
<div class="admin-role-name">{{ role.name }}</div>
|
||
<div class="admin-role-desc">{{ role.description || '未填写描述' }}</div>
|
||
<div class="admin-role-perms">
|
||
权限:{{ role.permissions.join(', ') || '未设置' }}
|
||
</div>
|
||
</div>
|
||
<div class="admin-actions">
|
||
<button type="button" class="admin-btn primary" @click="startRoleEdit(role)">
|
||
编辑
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="admin-btn danger"
|
||
@click="deleteRole(role)"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="adminActiveTab === 'home_push'" class="admin-section">
|
||
<div class="admin-section-header">
|
||
<div>
|
||
<div class="admin-section-title">首页推送设定</div>
|
||
<div class="admin-section-sub">配置首页“广场”和“精选”模块的文章(前10条)</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="admin-card admin-table-card">
|
||
<div class="admin-table-wrapper">
|
||
<table class="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 60px">顺序</th>
|
||
<th>文章标题</th>
|
||
<th>作者</th>
|
||
<th>状态</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(article, index) in homeFeaturedArticles" :key="article.slug">
|
||
<td>
|
||
<span class="admin-badge">{{ index + 1 }}</span>
|
||
</td>
|
||
<td>
|
||
<div class="admin-article-title">{{ article.title }}</div>
|
||
<div class="admin-user-meta">{{ article.slug }}</div>
|
||
</td>
|
||
<td>{{ article.author?.username }}</td>
|
||
<td>
|
||
<span v-if="index < 5" class="status-tag success">广场列表</span>
|
||
<span v-else class="status-tag warning">更多精选</span>
|
||
</td>
|
||
<td class="admin-actions">
|
||
<button
|
||
type="button"
|
||
class="admin-btn"
|
||
:disabled="index === 0"
|
||
@click="moveArticle(index, -1)"
|
||
>
|
||
↑
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="admin-btn"
|
||
:disabled="index === homeFeaturedArticles.length - 1"
|
||
@click="moveArticle(index, 1)"
|
||
>
|
||
↓
|
||
</button>
|
||
<button type="button" class="admin-btn danger" @click="removeFeatured(index)">
|
||
移除
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="admin-card-footer">
|
||
<p class="admin-hint">提示:实际项目中此处应连接后端 API 保存配置。</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="admin-section">
|
||
<div class="admin-section-header">
|
||
<div>
|
||
<div class="admin-section-title">文章管理</div>
|
||
<div class="admin-section-sub">批量管理所有文章</div>
|
||
</div>
|
||
<div class="admin-filter-row">
|
||
<input
|
||
v-model="adminFilters.articleSearch"
|
||
class="admin-input"
|
||
type="text"
|
||
placeholder="搜索标题 / 描述"
|
||
/>
|
||
<input
|
||
v-model="adminFilters.articleAuthor"
|
||
class="admin-input"
|
||
type="text"
|
||
placeholder="作者用户名"
|
||
/>
|
||
<button type="button" class="admin-btn" @click="loadAdminArticles">
|
||
查询
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="admin-card admin-table-card">
|
||
<div v-if="adminLoading.articles" class="admin-empty">加载中...</div>
|
||
<div v-else-if="adminArticles.length === 0" class="admin-empty">暂无文章</div>
|
||
<div v-else class="admin-table-wrapper">
|
||
<table class="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th>标题</th>
|
||
<th>作者</th>
|
||
<th>浏览</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="article in adminArticles" :key="article.slug">
|
||
<td>
|
||
<template v-if="articleEdit?.slug === article.slug">
|
||
<input
|
||
v-model="articleEdit.title"
|
||
class="admin-input"
|
||
type="text"
|
||
/>
|
||
<textarea
|
||
v-model="articleEdit.description"
|
||
class="admin-input"
|
||
rows="2"
|
||
/>
|
||
</template>
|
||
<template v-else>
|
||
<div class="admin-article-title">{{ article.title }}</div>
|
||
<div class="admin-user-meta">Slug:{{ article.slug }}</div>
|
||
</template>
|
||
</td>
|
||
<td>{{ article.author?.username || '未知' }}</td>
|
||
<td>{{ article.views ?? 0 }}</td>
|
||
<td class="admin-actions">
|
||
<template v-if="articleEdit?.slug === article.slug">
|
||
<button type="button" class="admin-btn primary" @click="saveArticleEdit">
|
||
保存
|
||
</button>
|
||
<button type="button" class="admin-btn" @click="cancelArticleEdit">
|
||
取消
|
||
</button>
|
||
</template>
|
||
<template v-else>
|
||
<button type="button" class="admin-btn primary" @click="startArticleEdit(article)">
|
||
编辑
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="admin-btn danger"
|
||
@click="deleteAdminArticle(article)"
|
||
>
|
||
删除
|
||
</button>
|
||
</template>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch, reactive, onMounted, onActivated, onBeforeUnmount } from 'vue'
|
||
import { navigateTo, useAsyncData } from '#app'
|
||
import { useRoute, onBeforeRouteLeave } from 'vue-router'
|
||
import { useAuth } from '@/composables/useAuth'
|
||
import { useAuthToken, useApi } from '@/composables/useApi'
|
||
import MarketCard from '../components/home/MarketCard.vue'
|
||
import InteractiveTechBackground from '../components/home/InteractiveTechBackground.vue'
|
||
import HomePlaza from '../components/home/HomePlaza.vue'
|
||
|
||
const adminTabs = [
|
||
{ label: '用户管理', value: 'users' },
|
||
{ label: '角色管理', value: 'roles' },
|
||
{ label: '文章管理', value: 'articles' },
|
||
{ label: '首页推送', value: 'home_push' },
|
||
] as const
|
||
|
||
const route = useRoute()
|
||
|
||
const homeFeaturedArticles = ref<ArticleItem[]>([])
|
||
const taggedArticles = ref<ArticleItem[]>([])
|
||
const activeTag = ref('全部')
|
||
|
||
const api = useApi()
|
||
|
||
// GET /api/home-featured-articles:首页推送前 10 条
|
||
const { data: homeFeaturedRes, pending: loadingHomeFeatured } = useAsyncData(
|
||
'home-featured-articles',
|
||
() => api.get('/home-featured-articles'),
|
||
{ server: false },
|
||
)
|
||
|
||
watch(homeFeaturedRes, (res: any) => {
|
||
const list = Array.isArray(res?.articles) ? res.articles : []
|
||
homeFeaturedArticles.value = list.slice(0, 10)
|
||
// 数据加载完成后再尝试恢复滚动
|
||
requestAnimationFrame(() => restoreScrollPosition())
|
||
})
|
||
|
||
const plazaArticles = computed(() => homeFeaturedArticles.value.slice(0, 5))
|
||
const moreFeaturedArticles = computed(() => homeFeaturedArticles.value.slice(5, 10))
|
||
|
||
// GET /api/articles:标签列表 + 按标签浏览
|
||
const {
|
||
data: tagRes,
|
||
pending: loadingTagArticles,
|
||
} = useAsyncData(
|
||
'tag-articles',
|
||
() => api.get('/articles', { limit: 60, offset: 0 }),
|
||
{ server: false }
|
||
)
|
||
|
||
watch(tagRes, (res: any) => {
|
||
taggedArticles.value = Array.isArray(res?.articles) ? res.articles : []
|
||
requestAnimationFrame(() => restoreScrollPosition())
|
||
})
|
||
|
||
const allTags = computed(() => {
|
||
const set = new Set<string>()
|
||
taggedArticles.value.forEach((article) => {
|
||
article.tagList?.forEach((tag) => set.add(tag))
|
||
})
|
||
const list = Array.from(set)
|
||
return list.length ? ['全部', ...list] : []
|
||
})
|
||
|
||
watch(allTags, (tags) => {
|
||
if (!tags.length) {
|
||
activeTag.value = ''
|
||
return
|
||
}
|
||
if (!tags.includes(activeTag.value)) {
|
||
activeTag.value = tags[0]
|
||
}
|
||
})
|
||
|
||
const filteredByTag = computed(() => {
|
||
if (!taggedArticles.value.length) return []
|
||
if (!activeTag.value || activeTag.value === '全部') return taggedArticles.value
|
||
return taggedArticles.value.filter((article) => article.tagList?.includes(activeTag.value))
|
||
})
|
||
|
||
const showTagsSection = computed(() => allTags.value.length > 0 && taggedArticles.value.length > 0)
|
||
|
||
async function toggleLike(article: ArticleItem): Promise<void> {
|
||
if (!article?.slug) return
|
||
if (!token.value) {
|
||
await navigateTo('/login')
|
||
return
|
||
}
|
||
try {
|
||
if (article.favorited) {
|
||
await api.del(`/articles/${article.slug}/favorite`)
|
||
article.favorited = false
|
||
article.favoritesCount = Math.max(0, (article.favoritesCount ?? 1) - 1)
|
||
syncLikeState({ slug: article.slug, favorited: false, favoritesCount: article.favoritesCount ?? 0 })
|
||
} else {
|
||
await api.post(`/articles/${article.slug}/favorite`)
|
||
article.favorited = true
|
||
article.favoritesCount = (article.favoritesCount ?? 0) + 1
|
||
syncLikeState({ slug: article.slug, favorited: true, favoritesCount: article.favoritesCount ?? 0 })
|
||
}
|
||
} catch (err) {
|
||
console.error('[Home] toggle like failed', err)
|
||
}
|
||
}
|
||
|
||
function syncLikeState(payload: { slug: string; favorited: boolean; favoritesCount: number }): void {
|
||
const { slug, favorited, favoritesCount } = payload
|
||
const apply = (list: ArticleItem[]) =>
|
||
list.map((it) =>
|
||
it.slug === slug
|
||
? { ...it, favorited, favoritesCount, likes: favoritesCount }
|
||
: it,
|
||
)
|
||
homeFeaturedArticles.value = apply(homeFeaturedArticles.value)
|
||
taggedArticles.value = apply(taggedArticles.value)
|
||
}
|
||
|
||
// -------- 滚动位置存取(防止返回首页时回到顶部) --------
|
||
const HOME_SCROLL_KEY = 'scroll:/'
|
||
let scrollRaf: number | null = null
|
||
let restoredOnce = false
|
||
|
||
function readSavedScroll(): { left: number; top: number } | null {
|
||
if (!process.client) return null
|
||
try {
|
||
const raw = sessionStorage.getItem(HOME_SCROLL_KEY)
|
||
if (!raw) return null
|
||
const pos = JSON.parse(raw)
|
||
if (typeof pos?.left === 'number' && typeof pos?.top === 'number') return pos
|
||
} catch (err) {
|
||
console.warn('[Home] read scroll failed', err)
|
||
}
|
||
return null
|
||
}
|
||
|
||
function saveHomeScroll(): void {
|
||
if (!process.client) return
|
||
try {
|
||
const pos = { left: window.scrollX, top: window.scrollY }
|
||
sessionStorage.setItem(HOME_SCROLL_KEY, JSON.stringify(pos))
|
||
} catch (err) {
|
||
console.warn('[Home] save scroll failed', err)
|
||
}
|
||
}
|
||
|
||
function restoreScrollPosition(attempt = 0): void {
|
||
if (!process.client || restoredOnce) return
|
||
const pos = readSavedScroll()
|
||
if (!pos) return
|
||
|
||
const maxAttempts = 15
|
||
const canScroll =
|
||
document.documentElement.scrollHeight - pos.top > window.innerHeight / 2 ||
|
||
attempt >= maxAttempts
|
||
|
||
if (canScroll) {
|
||
window.scrollTo({ left: pos.left, top: pos.top, behavior: 'auto' })
|
||
restoredOnce = true
|
||
return
|
||
}
|
||
|
||
requestAnimationFrame(() => restoreScrollPosition(attempt + 1))
|
||
}
|
||
|
||
onMounted(() => {
|
||
// 双 RAF + nextTick,保证内容高度准备好后再恢复
|
||
requestAnimationFrame(() => restoreScrollPosition())
|
||
|
||
const onScroll = () => {
|
||
if (scrollRaf) cancelAnimationFrame(scrollRaf)
|
||
scrollRaf = requestAnimationFrame(() => {
|
||
scrollRaf = null
|
||
saveHomeScroll()
|
||
})
|
||
}
|
||
window.addEventListener('scroll', onScroll, { passive: true })
|
||
window.addEventListener('beforeunload', saveHomeScroll)
|
||
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('scroll', onScroll)
|
||
window.removeEventListener('beforeunload', saveHomeScroll)
|
||
if (scrollRaf) cancelAnimationFrame(scrollRaf)
|
||
})
|
||
})
|
||
|
||
onActivated(() => {
|
||
requestAnimationFrame(() => restoreScrollPosition())
|
||
})
|
||
|
||
onBeforeRouteLeave(() => {
|
||
saveHomeScroll()
|
||
})
|
||
|
||
// Admin Logic for Home Push(保留内嵌管理面板的排序/移除功能)
|
||
function moveArticle(index: number, direction: number) {
|
||
const newIndex = index + direction
|
||
if (newIndex < 0 || newIndex >= homeFeaturedArticles.value.length) return
|
||
const temp = homeFeaturedArticles.value[index]
|
||
homeFeaturedArticles.value[index] = homeFeaturedArticles.value[newIndex]
|
||
homeFeaturedArticles.value[newIndex] = temp
|
||
}
|
||
|
||
function removeFeatured(index: number) {
|
||
homeFeaturedArticles.value.splice(index, 1)
|
||
}
|
||
|
||
const adminActiveTab = ref<(typeof adminTabs)[number]['value']>('users')
|
||
const enableInlineAdminPanel = false
|
||
|
||
interface ArticleItem {
|
||
slug: string
|
||
title: string
|
||
description?: string | null
|
||
body?: string
|
||
tagList?: string[]
|
||
cover?: string | null
|
||
favorited?: boolean
|
||
views?: number
|
||
author?: {
|
||
username?: string
|
||
image?: string | null
|
||
}
|
||
favoritesCount?: number
|
||
createdAt?: string
|
||
updatedAt?: string
|
||
}
|
||
|
||
interface ArticlesListResponse {
|
||
articles?: ArticleItem[]
|
||
articles_count?: number
|
||
articlesCount?: number
|
||
}
|
||
|
||
interface CurrentUser {
|
||
username: string
|
||
image?: string | null
|
||
email?: string
|
||
roles?: string[]
|
||
}
|
||
|
||
interface AdminRole {
|
||
id: number
|
||
name: string
|
||
description?: string | null
|
||
permissions: string[]
|
||
}
|
||
|
||
interface AdminUser {
|
||
id: number
|
||
username: string
|
||
email: string
|
||
bio?: string | null
|
||
image?: string | null
|
||
roles: AdminRole[]
|
||
created_at?: string
|
||
updated_at?: string
|
||
}
|
||
|
||
interface AdminDashboardStats {
|
||
users: number
|
||
roles: number
|
||
articles: number
|
||
total_views: number
|
||
published_today: number
|
||
}
|
||
|
||
interface AdminUsersResponse {
|
||
users?: AdminUser[]
|
||
total?: number
|
||
}
|
||
|
||
interface AdminRolesResponse {
|
||
roles?: AdminRole[]
|
||
}
|
||
|
||
interface AdminArticlesResponse {
|
||
articles?: ArticleItem[]
|
||
articles_count?: number
|
||
articlesCount?: number
|
||
}
|
||
|
||
const defaultCover = '/cover.jpg'
|
||
|
||
const { token } = useAuthToken()
|
||
const { user: authUser, fetchMe } = useAuth()
|
||
|
||
|
||
|
||
const currentUser = ref<CurrentUser | null>(
|
||
(authUser.value || null) as CurrentUser | null,
|
||
)
|
||
|
||
watch(
|
||
() => authUser.value,
|
||
(val) => {
|
||
currentUser.value = (val || null) as CurrentUser | null
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
watch(
|
||
() => token.value,
|
||
async (val) => {
|
||
if (!val) {
|
||
currentUser.value = null
|
||
return
|
||
}
|
||
try {
|
||
if (!currentUser.value) {
|
||
await fetchMe()
|
||
}
|
||
} catch (err) {
|
||
console.error('[Home] fetch current user failed:', err)
|
||
currentUser.value = null
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
const isLoggedIn = computed<boolean>(() => {
|
||
return Boolean(token.value && currentUser.value)
|
||
})
|
||
|
||
const isAdmin = computed<boolean>(() => {
|
||
if (!isLoggedIn.value) return false
|
||
const fallbackUser = (authUser.value || null) as CurrentUser | null
|
||
const roles = currentUser.value?.roles ?? fallbackUser?.roles ?? []
|
||
return Array.isArray(roles) && roles.includes('admin')
|
||
})
|
||
|
||
const showAdminPanel = computed<boolean>(() => {
|
||
return enableInlineAdminPanel && isLoggedIn.value && isAdmin.value
|
||
})
|
||
|
||
const adminStats = ref<AdminDashboardStats | null>(null)
|
||
const adminUsers = ref<AdminUser[]>([])
|
||
const adminUsersTotal = ref(0)
|
||
const adminRoles = ref<AdminRole[]>([])
|
||
const adminArticles = ref<ArticleItem[]>([])
|
||
const adminFilters = reactive({
|
||
userSearch: '',
|
||
userRoleId: '' as string | number,
|
||
articleSearch: '',
|
||
articleAuthor: '',
|
||
})
|
||
const newUserForm = reactive({
|
||
username: '',
|
||
email: '',
|
||
password: '',
|
||
bio: '',
|
||
roleIds: [] as number[],
|
||
})
|
||
const editingUser = ref<
|
||
| {
|
||
id: number
|
||
username: string
|
||
email: string
|
||
bio?: string | null
|
||
roleIds: number[]
|
||
}
|
||
| null
|
||
>(null)
|
||
const editingRole = reactive({
|
||
id: null as number | null,
|
||
name: '',
|
||
description: '',
|
||
permissionsInput: '',
|
||
})
|
||
const articleEdit = ref<{ slug: string; title: string; description: string | null } | null>(null)
|
||
const adminLoading = reactive({
|
||
stats: false,
|
||
users: false,
|
||
roles: false,
|
||
articles: false,
|
||
createUser: false,
|
||
saveUser: false,
|
||
saveRole: false,
|
||
saveArticle: false,
|
||
})
|
||
|
||
watch(isAdmin, async (val) => {
|
||
if (!enableInlineAdminPanel) return
|
||
if (val) {
|
||
await initAdminPanel()
|
||
} else {
|
||
adminStats.value = null
|
||
adminUsers.value = []
|
||
adminUsersTotal.value = 0
|
||
adminRoles.value = []
|
||
adminArticles.value = []
|
||
}
|
||
})
|
||
|
||
watch(adminActiveTab, async (tab) => {
|
||
if (!enableInlineAdminPanel || !isAdmin.value) return
|
||
if (tab === 'users' && !adminUsers.value.length) {
|
||
await loadAdminUsers()
|
||
} else if (tab === 'roles' && !adminRoles.value.length) {
|
||
await loadAdminRoles()
|
||
} else if (tab === 'articles' && !adminArticles.value.length) {
|
||
await loadAdminArticles()
|
||
}
|
||
})
|
||
|
||
|
||
|
||
function formatDate(value?: string | null): string {
|
||
if (!value) return '--'
|
||
try {
|
||
return new Date(value).toLocaleDateString()
|
||
} catch (e) {
|
||
return '--'
|
||
}
|
||
}
|
||
|
||
async function initAdminPanel(): Promise<void> {
|
||
if (!enableInlineAdminPanel) return
|
||
await Promise.all([loadAdminStats(), loadAdminRoles()])
|
||
await Promise.all([loadAdminUsers(), loadAdminArticles()])
|
||
}
|
||
|
||
async function loadAdminStats(): Promise<void> {
|
||
if (!isAdmin.value) return
|
||
adminLoading.stats = true
|
||
try {
|
||
const res = (await api.get('/admin/dashboard')) as Partial<AdminDashboardStats>
|
||
adminStats.value = res
|
||
? ({
|
||
users: Number(res.users ?? 0),
|
||
roles: Number(res.roles ?? 0),
|
||
articles: Number(res.articles ?? 0),
|
||
total_views: Number(res.total_views ?? 0),
|
||
published_today: Number(res.published_today ?? 0),
|
||
} as AdminDashboardStats)
|
||
: null
|
||
} catch (err) {
|
||
console.error('[Admin] load stats failed', err)
|
||
} finally {
|
||
adminLoading.stats = false
|
||
}
|
||
}
|
||
|
||
async function loadAdminUsers(): Promise<void> {
|
||
if (!isAdmin.value) return
|
||
adminLoading.users = true
|
||
try {
|
||
const query: Record<string, any> = { limit: 50, offset: 0 }
|
||
const search = adminFilters.userSearch.trim()
|
||
if (search) query.search = search
|
||
const roleId = Number(adminFilters.userRoleId)
|
||
if (!Number.isNaN(roleId) && roleId > 0) {
|
||
query.role_id = roleId
|
||
}
|
||
const res = (await api.get('/admin/users', query)) as AdminUsersResponse
|
||
const list = Array.isArray(res.users) ? res.users : []
|
||
adminUsers.value = list
|
||
adminUsersTotal.value = typeof res.total === 'number' ? res.total : list.length
|
||
} catch (err) {
|
||
console.error('[Admin] load users failed', err)
|
||
alert('加载用户列表失败')
|
||
} finally {
|
||
adminLoading.users = false
|
||
}
|
||
}
|
||
|
||
async function loadAdminRoles(): Promise<void> {
|
||
if (!isAdmin.value) return
|
||
adminLoading.roles = true
|
||
try {
|
||
const res = (await api.get('/admin/roles')) as AdminRolesResponse
|
||
adminRoles.value = Array.isArray(res.roles) ? res.roles : []
|
||
} catch (err) {
|
||
console.error('[Admin] load roles failed', err)
|
||
alert('加载角色列表失败')
|
||
} finally {
|
||
adminLoading.roles = false
|
||
}
|
||
}
|
||
|
||
async function loadAdminArticles(): Promise<void> {
|
||
if (!isAdmin.value) return
|
||
adminLoading.articles = true
|
||
try {
|
||
const query: Record<string, any> = { limit: 50, offset: 0 }
|
||
const keyword = adminFilters.articleSearch.trim()
|
||
if (keyword) query.search = keyword
|
||
const author = adminFilters.articleAuthor.trim()
|
||
if (author) query.author = author
|
||
const res = (await api.get('/admin/articles', query)) as AdminArticlesResponse
|
||
adminArticles.value = Array.isArray(res.articles) ? res.articles : []
|
||
} catch (err) {
|
||
console.error('[Admin] load articles failed', err)
|
||
alert('加载文章列表失败')
|
||
} finally {
|
||
adminLoading.articles = false
|
||
}
|
||
}
|
||
|
||
function resetNewUserForm(): void {
|
||
newUserForm.username = ''
|
||
newUserForm.email = ''
|
||
newUserForm.password = ''
|
||
newUserForm.bio = ''
|
||
newUserForm.roleIds = []
|
||
}
|
||
|
||
async function submitNewUser(): Promise<void> {
|
||
if (!isAdmin.value) return
|
||
if (!newUserForm.username.trim() || !newUserForm.email.trim() || !newUserForm.password.trim()) {
|
||
alert('请完整填写用户名、邮箱和密码')
|
||
return
|
||
}
|
||
adminLoading.createUser = true
|
||
try {
|
||
await api.post('/admin/users', {
|
||
user: {
|
||
username: newUserForm.username.trim(),
|
||
email: newUserForm.email.trim(),
|
||
password: newUserForm.password,
|
||
bio: newUserForm.bio,
|
||
role_ids: newUserForm.roleIds,
|
||
},
|
||
})
|
||
resetNewUserForm()
|
||
await Promise.all([loadAdminUsers(), loadAdminStats()])
|
||
} catch (err: any) {
|
||
console.error('[Admin] create user failed', err)
|
||
alert(err?.statusMessage || '创建用户失败')
|
||
} finally {
|
||
adminLoading.createUser = false
|
||
}
|
||
}
|
||
|
||
function startUserEdit(user: AdminUser): void {
|
||
editingUser.value = {
|
||
id: user.id,
|
||
username: user.username,
|
||
email: user.email,
|
||
bio: user.bio || '',
|
||
roleIds: (user.roles || []).map((r) => r.id),
|
||
}
|
||
}
|
||
|
||
function cancelUserEdit(): void {
|
||
editingUser.value = null
|
||
}
|
||
|
||
async function saveUserEdit(): Promise<void> {
|
||
if (!editingUser.value) return
|
||
adminLoading.saveUser = true
|
||
try {
|
||
await api.put(`/admin/users/${editingUser.value.id}`, {
|
||
user: {
|
||
username: editingUser.value.username,
|
||
email: editingUser.value.email,
|
||
bio: editingUser.value.bio,
|
||
role_ids: editingUser.value.roleIds,
|
||
},
|
||
})
|
||
editingUser.value = null
|
||
await Promise.all([loadAdminUsers(), loadAdminStats()])
|
||
} catch (err: any) {
|
||
console.error('[Admin] save user failed', err)
|
||
alert(err?.statusMessage || '保存用户失败')
|
||
} finally {
|
||
adminLoading.saveUser = false
|
||
}
|
||
}
|
||
|
||
async function deleteAdminUser(user: AdminUser): Promise<void> {
|
||
if (!user?.id) return
|
||
if (!confirm(`确定删除用户 ${user.username} 吗?`)) return
|
||
try {
|
||
await api.del(`/admin/users/${user.id}`)
|
||
await Promise.all([loadAdminUsers(), loadAdminStats()])
|
||
} catch (err: any) {
|
||
console.error('[Admin] delete user failed', err)
|
||
alert(err?.statusMessage || '删除用户失败')
|
||
}
|
||
}
|
||
|
||
function startRoleEdit(role: AdminRole): void {
|
||
editingRole.id = role.id
|
||
editingRole.name = role.name
|
||
editingRole.description = role.description || ''
|
||
editingRole.permissionsInput = (role.permissions || []).join(', ')
|
||
}
|
||
|
||
function resetRoleForm(): void {
|
||
editingRole.id = null
|
||
editingRole.name = ''
|
||
editingRole.description = ''
|
||
editingRole.permissionsInput = ''
|
||
}
|
||
|
||
async function submitRoleForm(): Promise<void> {
|
||
if (!editingRole.name.trim()) {
|
||
alert('请输入角色名称')
|
||
return
|
||
}
|
||
adminLoading.saveRole = true
|
||
const permissions = editingRole.permissionsInput
|
||
.split(',')
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
const payload = {
|
||
role: {
|
||
name: editingRole.name.trim(),
|
||
description: editingRole.description,
|
||
permissions,
|
||
},
|
||
}
|
||
try {
|
||
if (editingRole.id) {
|
||
await api.put(`/admin/roles/${editingRole.id}`, payload)
|
||
} else {
|
||
await api.post('/admin/roles', payload)
|
||
}
|
||
resetRoleForm()
|
||
await loadAdminRoles()
|
||
} catch (err: any) {
|
||
console.error('[Admin] save role failed', err)
|
||
alert(err?.statusMessage || '保存角色失败')
|
||
} finally {
|
||
adminLoading.saveRole = false
|
||
}
|
||
}
|
||
|
||
async function deleteRole(role: AdminRole): Promise<void> {
|
||
if (role.name === 'admin') {
|
||
alert('admin 角色不允许删除')
|
||
return
|
||
}
|
||
if (!confirm(`确定删除角色 ${role.name} 吗?`)) return
|
||
try {
|
||
await api.del(`/admin/roles/${role.id}`)
|
||
await loadAdminRoles()
|
||
} catch (err: any) {
|
||
console.error('[Admin] delete role failed', err)
|
||
alert(err?.statusMessage || '删除角色失败')
|
||
}
|
||
}
|
||
|
||
function startArticleEdit(article: ArticleItem): void {
|
||
articleEdit.value = {
|
||
slug: article.slug,
|
||
title: article.title,
|
||
description: article.description || '',
|
||
}
|
||
}
|
||
|
||
function cancelArticleEdit(): void {
|
||
articleEdit.value = null
|
||
}
|
||
|
||
async function saveArticleEdit(): Promise<void> {
|
||
if (!articleEdit.value) return
|
||
adminLoading.saveArticle = true
|
||
try {
|
||
await api.put(`/admin/articles/${articleEdit.value.slug}`, {
|
||
article: {
|
||
title: articleEdit.value.title,
|
||
description: articleEdit.value.description,
|
||
},
|
||
})
|
||
articleEdit.value = null
|
||
await loadAdminArticles()
|
||
} catch (err: any) {
|
||
console.error('[Admin] save article failed', err)
|
||
alert(err?.statusMessage || '保存文章失败')
|
||
} finally {
|
||
adminLoading.saveArticle = false
|
||
}
|
||
}
|
||
|
||
async function deleteAdminArticle(article: ArticleItem): Promise<void> {
|
||
if (!article?.slug) return
|
||
if (!confirm(`确定删除文章「${article.title}」吗?`)) return
|
||
try {
|
||
await api.del(`/admin/articles/${article.slug}`)
|
||
await Promise.all([loadAdminArticles(), loadAdminStats()])
|
||
} catch (err: any) {
|
||
console.error('[Admin] delete article failed', err)
|
||
alert(err?.statusMessage || '删除文章失败')
|
||
}
|
||
}
|
||
|
||
|
||
</script>
|
||
|
||
<style scoped>
|
||
.home-container {
|
||
padding: 0 5px;
|
||
}
|
||
|
||
/* 顶部 Banner */
|
||
.home-height {
|
||
height: 65vh;
|
||
position: relative;
|
||
}
|
||
.home-banner {
|
||
position: absolute;
|
||
inset: 0;
|
||
background-color: #f8faff; /* Light background */
|
||
overflow: hidden;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.neon-sun-wrapper {
|
||
position: absolute;
|
||
top: -1750px; /* Moved much higher to reduce visible arc */
|
||
left: 50%;
|
||
width: 2000px;
|
||
height: 2000px;
|
||
transform: translateX(-50%);
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
}
|
||
|
||
.neon-sun {
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 50%;
|
||
background: conic-gradient(
|
||
from 180deg,
|
||
#ff0080,
|
||
#7928ca,
|
||
#4f46e5,
|
||
#0ea5e9,
|
||
#4f46e5,
|
||
#7928ca,
|
||
#ff0080
|
||
);
|
||
/* Strong blur for the misty look */
|
||
filter: blur(100px);
|
||
opacity: 0.7;
|
||
animation: sun-spin 80s linear infinite;
|
||
}
|
||
|
||
.solar-flare {
|
||
position: absolute;
|
||
bottom: 40px; /* Positioned within the blur */
|
||
left: 50%;
|
||
width: 30px;
|
||
height: 100px;
|
||
background: linear-gradient(to top, transparent, rgba(255, 0, 128, 0.6), rgba(14, 165, 233, 0.8));
|
||
transform-origin: bottom center;
|
||
transform: translateX(-50%) rotate(var(--angle)) scaleY(0);
|
||
/* Blur the flares too so they blend with the mist */
|
||
filter: blur(20px);
|
||
opacity: 0;
|
||
animation: flare-erupt 5s ease-in-out infinite;
|
||
animation-delay: var(--delay);
|
||
border-radius: 50% 50% 0 0;
|
||
}
|
||
|
||
@keyframes sun-spin {
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
@keyframes flare-erupt {
|
||
0% { transform: translateX(-50%) rotate(var(--angle)) scaleY(0.5); opacity: 0; }
|
||
20% { opacity: 0.6; }
|
||
50% { transform: translateX(-50%) rotate(var(--angle)) scaleY(1.2); opacity: 0.3; }
|
||
100% { transform: translateX(-50%) rotate(var(--angle)) scaleY(0.5); opacity: 0; }
|
||
}
|
||
|
||
.watermark {
|
||
position: absolute;
|
||
top: 43%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
font-size: 10rem;
|
||
font-weight: 900;
|
||
color: rgba(0, 0, 0, 0.03); /* Dark watermark for light bg */
|
||
pointer-events: none;
|
||
user-select: none;
|
||
z-index: 1;
|
||
letter-spacing: 0.5rem;
|
||
font-family: sans-serif;
|
||
}
|
||
|
||
.home-banner-content {
|
||
position: relative;
|
||
z-index: 2;
|
||
text-align: center;
|
||
margin-top: 120px;
|
||
}
|
||
|
||
.main-title {
|
||
font-size: 3.5rem;
|
||
font-weight: 900;
|
||
color: #0f172a; /* Dark text */
|
||
margin-bottom: 1.5rem;
|
||
letter-spacing: 0.2rem;
|
||
/* Removed heavy text shadow */
|
||
}
|
||
|
||
.sub-title {
|
||
font-size: 1.5rem;
|
||
color: #334155; /* Dark gray text */
|
||
font-weight: 700;
|
||
}
|
||
|
||
/* Tech Elements */
|
||
.tech-grid {
|
||
position: absolute;
|
||
inset: 0;
|
||
background-image:
|
||
linear-gradient(rgba(99, 102, 241, 0.1) 1px, transparent 1px),
|
||
linear-gradient(90deg, rgba(99, 102, 241, 0.1) 1px, transparent 1px);
|
||
background-size: 40px 40px;
|
||
mask-image: radial-gradient(circle at center, black 40%, transparent 80%);
|
||
-webkit-mask-image: radial-gradient(circle at center, black 40%, transparent 80%);
|
||
z-index: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.tech-scanline {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 2px;
|
||
background: linear-gradient(90deg, transparent, rgba(99, 102, 241, 0.5), transparent);
|
||
animation: scanline 4s linear infinite;
|
||
z-index: 1;
|
||
pointer-events: none;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
@keyframes scanline {
|
||
0% { top: 0%; opacity: 0; }
|
||
10% { opacity: 0.5; }
|
||
90% { opacity: 0.5; }
|
||
100% { top: 100%; opacity: 0; }
|
||
}
|
||
|
||
/* Removed .tech-particles and .tech-particle CSS as they are replaced by the component */
|
||
|
||
.home-banner-content {
|
||
position: relative;
|
||
z-index: 2;
|
||
text-align: center;
|
||
margin-top: 40px;
|
||
}
|
||
|
||
.main-title {
|
||
font-size: 3.5rem;
|
||
font-weight: 900;
|
||
color: #0f172a;
|
||
margin-bottom: 1.5rem;
|
||
letter-spacing: 0.2rem;
|
||
}
|
||
|
||
.sub-title {
|
||
font-size: 1.5rem;
|
||
color: #334155;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.home-banner-right {
|
||
position: absolute;
|
||
top: 0;
|
||
right: 5%;
|
||
width: 35%;
|
||
height: 100%;
|
||
z-index: 2;
|
||
pointer-events: none;
|
||
}
|
||
|
||
@media (max-width: 1024px) {
|
||
.home-banner-right {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
/* New Homepage Sections */
|
||
.home-section-container {
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
padding: 0 32px;
|
||
margin-bottom: 64px;
|
||
}
|
||
|
||
.loading-card {
|
||
margin: 0 0 24px 0;
|
||
background: #ffffff;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 16px;
|
||
padding: 18px;
|
||
color: #6b7280;
|
||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06);
|
||
}
|
||
|
||
.empty {
|
||
padding: 20px 12px;
|
||
text-align: center;
|
||
color: #9ca3af;
|
||
background: #f8fafc;
|
||
border-radius: 12px;
|
||
border: 1px dashed #e5e7eb;
|
||
}
|
||
|
||
.more-featured-section {
|
||
margin-top: 48px;
|
||
}
|
||
|
||
.section-header {
|
||
margin-bottom: 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 24px;
|
||
font-weight: 800;
|
||
color: var(--color-text-main);
|
||
letter-spacing: -0.5px;
|
||
position: relative;
|
||
padding-left: 16px;
|
||
}
|
||
|
||
.section-title::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 0;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
width: 4px;
|
||
height: 20px;
|
||
background: var(--color-primary);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
/* Responsive Grid for Cards */
|
||
.cards-grid-responsive {
|
||
display: grid;
|
||
grid-template-columns: repeat(5, 1fr);
|
||
gap: 24px;
|
||
}
|
||
|
||
@media (max-width: 1280px) {
|
||
.cards-grid-responsive {
|
||
grid-template-columns: repeat(4, 1fr);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 1024px) {
|
||
.cards-grid-responsive {
|
||
grid-template-columns: repeat(3, 1fr);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.cards-grid-responsive {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.cards-grid-responsive {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.home-section-container {
|
||
padding: 0 20px;
|
||
margin-bottom: 48px;
|
||
}
|
||
}
|
||
|
||
/* Tags Section */
|
||
.tags-section {
|
||
margin-top: 48px;
|
||
}
|
||
|
||
.tags-tabs {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.tag-pill {
|
||
padding: 8px 20px;
|
||
border-radius: 99px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: var(--color-text-sub);
|
||
background: #fff;
|
||
border: 1px solid var(--color-border-light);
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.tag-pill:hover {
|
||
border-color: var(--color-primary-end);
|
||
color: var(--color-primary-end);
|
||
}
|
||
|
||
.tag-pill.active {
|
||
background: var(--color-primary-end);
|
||
color: #fff;
|
||
border-color: var(--color-primary-end);
|
||
font-weight: 600;
|
||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.25);
|
||
}
|
||
|
||
/* Admin Status Tags */
|
||
.status-tag {
|
||
display: inline-block;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.status-tag.success {
|
||
background: #dcfce7;
|
||
color: #166534;
|
||
}
|
||
|
||
.status-tag.warning {
|
||
background: #fef9c3;
|
||
color: #854d0e;
|
||
}
|
||
|
||
/* Small Screen Adaptations */
|
||
@media (max-width: 640px) {
|
||
.banner-title {
|
||
font-size: 2.2rem;
|
||
}
|
||
}
|
||
|
||
</style>
|