1588 lines
45 KiB
Vue
Raw Permalink Normal View History

2025-12-04 10:04:21 +08:00
<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>