2025-12-04 10:04:21 +08:00

1588 lines
45 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>