feat: 初始化云升数码展示页面项目 - Vue 3 + PocketBase 全栈架构

This commit is contained in:
2026-07-24 14:36:10 +08:00
commit 928c62475a
12 changed files with 1439 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0ea5e9" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+62
View File
@@ -0,0 +1,62 @@
{
"name": "web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.ts,.jsx,.tsx",
"format": "prettier --write .",
"deploy": "node scripts/deploy.js"
},
"dependencies": {
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"pinia": "^2.1.7",
"pocketbase": "^0.21.1",
"@tiptap/vue-3": "^2.2.4",
"@tiptap/starter-kit": "^2.2.4",
"@tiptap/extension-image": "^2.2.4",
"@tiptap/extension-video": "^2.2.4",
"@tiptap/extension-audio": "^2.2.4",
"@tiptap/extension-code-block-lowlight": "^2.2.4",
"@tiptap/extension-link": "^2.2.4",
"@tiptap/extension-placeholder": "^2.2.4",
"@tiptap/extension-task-list": "^2.2.4",
"@tiptap/extension-task-item": "^2.2.4",
"@tiptap/extension-table": "^2.2.4",
"@tiptap/extension-table-row": "^2.2.4",
"@tiptap/extension-table-cell": "^2.2.4",
"@tiptap/extension-table-header": "^2.2.4",
"@tiptap/extension-highlight": "^2.2.4",
"@tiptap/extension-underline": "^2.2.4",
"lowlight": "^3.1.0",
"@heroicons/vue": "^2.1.3",
"vueuse": "^10.9.0",
"nprogress": "^0.2.0",
"date-fns": "^3.3.1",
"markdown-it": "^14.1.0",
"markdown-it-anchor": "^9.0.1",
"markdown-it-toc-done-right": "^4.2.0",
"markdown-it-footnote": "^4.0.0",
"markdown-it-container": "^4.0.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"typescript": "^5.3.3",
"vite": "^5.1.6",
"tailwindcss": "^3.4.1",
"postcss": "^8.4.35",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.22.0",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.12",
"vite-plugin-pwa": "^0.19.2",
"workbox-window": "^7.0.0"
}
}
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import { onMounted } from 'vue'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
// 配置 NProgress
NProgress.configure({
showSpinner: false,
minimum: 0.2,
trickleSpeed: 200,
})
// 全局音乐播放器状态
import { useMusicStore } from '@/stores/music'
const musicStore = useMusicStore()
onMounted(() => {
musicStore.init()
})
</script>
<template>
<div class="app min-h-screen bg-surface text-text-primary dark:bg-slate-950 dark:text-slate-50">
<!-- 全局音乐播放器 -->
<GlobalMusicPlayer v-if="musicStore.hasMusic" />
<!-- Toast 容器 -->
<ToastContainer />
<!-- 主内容区 -->
<RouterView v-slot="{ Component }">
<transition name="page" mode="out-in">
<component :is="Component" />
</transition>
</RouterView>
<!-- 页面过渡动画样式 -->
<style>
.page-enter-active,
.page-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.page-enter-from,
.page-leave-to {
opacity: 0;
transform: translateY(10px);
}
</style>
</div>
</template>
<script setup lang="ts">
import GlobalMusicPlayer from '@/components/GlobalMusicPlayer.vue'
import ToastContainer from '@/components/ToastContainer.vue'
import { useMusicStore } from '@/stores/music'
</script>
+188
View File
@@ -0,0 +1,188 @@
import PocketBase from 'pocketbase'
import { ref } from 'vue'
// 单例模式确保全局只有一个 PocketBase 实例
const pbInstance = new PocketBase(import.meta.env.VITE_POCKETBASE_URL || 'http://127.0.0.1:8090')
// 实时连接状态
export const isConnected = ref(false)
// 监听连接状态
pbInstance.autoCancellation(false)
export const pb = pbInstance
// 导出类型
export type { RecordModel, AuthModel } from 'pocketbase'
// 通用 API 封装
export function usePocketBase() {
const pb = pbInstance
// 列表查询封装
async function getList<T = any>(
collection: string,
options: {
page?: number
perPage?: number
filter?: string
sort?: string
expand?: string
fields?: string
} = {}
) {
const { page = 1, perPage = 30, filter = '', sort = '-created', expand = '', fields = '' } = options
return pb.collection(collection).getList<T>(page, perPage, {
filter,
sort,
expand,
fields,
})
}
// 获取单条记录
async function getOne<T = any>(collection: string, id: string, expand = '') {
return pb.collection(collection).getOne<T>(id, { expand })
}
// 获取完整列表(不分页)
async function getFullList<T = any>(collection: string, options: { filter?: string; sort?: string; expand?: string } = {}) {
return pb.collection(collection).getFullList<T>({
sort: '-created',
...options,
})
}
// 创建记录
async function create<T = any>(collection: string, data: any, files?: File[]) {
const formData = new FormData()
Object.entries(data).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (Array.isArray(value)) {
value.forEach(v => formData.append(key, v))
} else {
formData.append(key, String(value))
}
}
})
if (files) {
files.forEach(file => formData.append('file', file))
}
return pb.collection(collection).create<T>(formData)
}
// 更新记录
async function update<T = any>(collection: string, id: string, data: any, files?: File[]) {
const formData = new FormData()
Object.entries(data).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (Array.isArray(value)) {
value.forEach(v => formData.append(key, v))
} else {
formData.append(key, String(value))
}
}
})
if (files) {
files.forEach(file => formData.append('file', file))
}
return pb.collection(collection).update<T>(id, formData)
}
// 删除记录
async function remove(collection: string, id: string) {
return pb.collection(collection).delete(id)
}
// 文件上传 URL
function getFileUrl(record: any, filename: string, thumb?: string) {
return pb.files.getUrl(record, filename, { thumb })
}
// 实时订阅
function subscribe(collection: string, callback: (data: any) => void) {
return pb.collection(collection).subscribe('*', callback)
}
function unsubscribe(collection: string) {
pb.collection(collection).unsubscribe()
}
return {
pb,
getList,
getOne,
getFullList,
create,
update,
remove,
getFileUrl,
subscribe,
unsubscribe,
}
}
// 认证相关
export function useAuth() {
const pb = pbInstance
async function login(email: string, password: string) {
return pb.collection('users').authWithPassword(email, password)
}
async function logout() {
pb.authStore.clear()
}
async function refresh() {
if (pb.authStore.isValid) {
return pb.collection('users').authRefresh()
}
return null
}
function getToken() {
return pb.authStore.token
}
return {
login,
logout,
refresh,
getToken,
get isValid() {
return pb.authStore.isValid
},
get user() {
return pb.authStore.model
},
}
}
// 实时连接管理
export function useRealtime() {
const pb = pbInstance
function connect() {
if (!isConnected.value) {
pb.realtime.init()
isConnected.value = true
}
}
function disconnect() {
if (isConnected.value) {
pb.realtime.disconnect()
isConnected.value = false
}
}
return {
connect,
disconnect,
get isConnected() {
return isConnected.value
},
}
}
+87
View File
@@ -0,0 +1,87 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createRouter, createWebHistory } from 'vue-router'
import App from './App.vue'
import './styles/main.css'
import 'nprogress/nprogress.css'
// 导入路由配置
import routes from './router'
// 创建 Pinia
const pinia = createPinia()
// 创建路由
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else if (to.hash) {
return { el: to.hash, behavior: 'smooth' }
} else {
return { top: 0 }
}
},
})
// 全局路由守卫
import NProgress from 'nprogress'
import { useAuthStore } from './stores/auth'
router.beforeEach(async (to, from, next) => {
NProgress.start()
const authStore = useAuthStore()
// 检查认证状态
if (!authStore.isAuthenticated && pb.authStore.isValid) {
await authStore.checkAuth()
}
// 需要认证的路由
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next({ path: '/login', query: { redirect: to.fullPath } })
return
}
// 仅管理员路由
if (to.meta.requiresAdmin && !authStore.isAdmin) {
next({ path: '/' })
return
}
// 已登录用户访问登录页重定向
if (to.path === '/login' && authStore.isAuthenticated) {
next({ path: '/admin' })
return
}
next()
})
router.afterEach(() => {
NProgress.done()
})
// 创建应用
const app = createApp(App)
app.use(pinia)
app.use(router)
// 全局错误处理
app.config.errorHandler = (err, instance, info) => {
console.error('Global error:', err, info)
}
// 挂载应用
app.mount('#app')
// 导出给其他模块使用
export { router, pinia }
// 导入 pb 实例
import { pb } from './composables/usePocketBase'
+177
View File
@@ -0,0 +1,177 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: () => import('@/views/HomeView.vue'),
meta: { title: '云升数码 - 专业数码评测与技术分享' },
},
{
path: '/articles',
name: 'Articles',
component: () => import('@/views/ArticlesView.vue'),
meta: { title: '文章列表 - 云升数码' },
},
{
path: '/article/:slug',
name: 'ArticleDetail',
component: () => import('@/views/ArticleDetailView.vue'),
meta: { title: '文章详情 - 云升数码' },
},
{
path: '/category/:slug',
name: 'Category',
component: () => import('@/views/CategoryView.vue'),
meta: { title: '分类 - 云升数码' },
},
{
path: '/tag/:slug',
name: 'Tag',
component: () => import('@/views/TagView.vue'),
meta: { title: '标签 - 云升数码' },
},
{
path: '/page/:slug',
name: 'Page',
component: () => import('@/views/PageView.vue'),
meta: { title: '页面 - 云升数码' },
},
{
path: '/music',
name: 'Music',
component: () => import('@/views/MusicView.vue'),
meta: { title: '音乐播放器 - 云升数码' },
},
{
path: '/search',
name: 'Search',
component: () => import('@/views/SearchView.vue'),
meta: { title: '搜索 - 云升数码' },
},
{
path: '/admin',
name: 'Admin',
component: () => import('@/views/admin/AdminLayout.vue'),
meta: { title: '后台管理 - 云升数码', requiresAuth: true, roles: ['admin'] },
children: [
{
path: '',
name: 'AdminDashboard',
component: () => import('@/views/admin/DashboardView.vue'),
meta: { title: '仪表盘 - 后台管理' },
},
{
path: 'posts',
name: 'AdminPosts',
component: () => import('@/views/admin/PostsView.vue'),
meta: { title: '文章管理 - 后台管理' },
},
{
path: 'posts/create',
name: 'AdminPostCreate',
component: () => import('@/views/admin/PostEditorView.vue'),
meta: { title: '新建文章 - 后台管理' },
},
{
path: 'posts/edit/:id',
name: 'AdminPostEdit',
component: () => import('@/views/admin/PostEditorView.vue'),
meta: { title: '编辑文章 - 后台管理' },
},
{
path: 'categories',
name: 'AdminCategories',
component: () => import('@/views/admin/CategoriesView.vue'),
meta: { title: '分类管理 - 后台管理' },
},
{
path: 'tags',
name: 'AdminTags',
component: () => import('@/views/admin/TagsView.vue'),
meta: { title: '标签管理 - 后台管理' },
},
{
path: 'pages',
name: 'AdminPages',
component: () => import('@/views/admin/PagesView.vue'),
meta: { title: '页面管理 - 后台管理' },
},
{
path: 'settings',
name: 'AdminSettings',
component: () => import('@/views/admin/SettingsView.vue'),
meta: { title: '站点设置 - 后台管理' },
},
{
path: 'media',
name: 'AdminMedia',
component: () => import('@/views/admin/MediaView.vue'),
meta: { title: '媒体库 - 后台管理' },
},
],
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/auth/LoginView.vue'),
meta: { title: '管理员登录 - 云升数码', guest: true },
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFoundView.vue'),
meta: { title: '404 - 页面未找到' },
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return { top: 0, behavior: 'smooth' }
}
},
})
// 路由守卫
import { useAuthStore } from '@/stores/auth'
import NProgress from 'nprogress'
router.beforeEach(async (to, from, next) => {
NProgress.start()
const authStore = useAuthStore()
// 设置页面标题
document.title = (to.meta.title as string) || '云升数码'
// 需要认证的页面
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
await authStore.checkAuth()
if (!authStore.isAuthenticated) {
return next({ name: 'Login', query: { redirect: to.fullPath } })
}
}
// 仅游客访问的页面(如登录页)
if (to.meta.guest && authStore.isAuthenticated) {
return next({ name: 'AdminDashboard' })
}
// 角色检查
if (to.meta.roles && !authStore.hasRole(to.meta.roles as string[])) {
return next({ name: 'Home' })
}
next()
})
router.afterEach(() => {
NProgress.done()
})
export default router
+82
View File
@@ -0,0 +1,82 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { pb } from '@/composables/usePocketBase'
import type { AuthModel, RecordModel } from 'pocketbase'
export const useAuthStore = defineStore('auth', () => {
// State
const user = ref<AuthModel | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
// Computed
const isAuthenticated = computed(() => !!user.value)
const isAdmin = computed(() => user.value?.role === 'admin')
// Actions
async function checkAuth() {
if (pb.authStore.isValid) {
try {
user.value = await pb.collection('users').authRefresh()
return true
} catch {
pb.authStore.clear()
user.value = null
return false
}
}
user.value = null
return false
}
async function login(email: string, password: string) {
isLoading.value = true
error.value = null
try {
const authData = await pb.collection('users').authWithPassword(email, password)
user.value = authData.record
return authData
} catch (err: any) {
error.value = err.message || '登录失败'
throw err
} finally {
isLoading.value = false
}
}
async function logout() {
pb.authStore.clear()
user.value = null
}
function hasRole(roles: string[]) {
if (!user.value) return false
return roles.includes(user.value.role)
}
// Initialize
if (pb.authStore.isValid) {
checkAuth()
}
// Listen for auth changes
pb.authStore.onChange(() => {
if (pb.authStore.isValid && !user.value) {
checkAuth()
} else if (!pb.authStore.isValid) {
user.value = null
}
})
return {
user,
isLoading,
error,
isAuthenticated,
isAdmin,
checkAuth,
login,
logout,
hasRole,
}
})
+554
View File
@@ -0,0 +1,554 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* Light theme colors */
--color-primary-50: 239 246 255;
--color-primary-100: 219 234 254;
--color-primary-200: 191 219 254;
--color-primary-300: 147 197 253;
--color-primary-400: 96 165 250;
--color-primary-500: 59 130 246;
--color-primary-600: 37 99 235;
--color-primary-700: 29 78 216;
--color-primary-800: 30 64 175;
--color-primary-900: 30 58 138;
--color-surface: 255 255 255;
--color-surface-hover: 248 250 252;
--color-surface-border: 226 232 240;
--color-text-primary: 15 23 42;
--color-text-secondary: 71 85 105;
--color-text-muted: 148 163 184;
--color-text-inverse: 255 255 255;
--color-accent-500: 14 165 233;
--color-accent-600: 2 132 199;
--color-success: 34 197 94;
--color-warning: 234 179 8;
--color-error: 239 68 68;
}
.dark {
--color-primary-50: 30 58 138;
--color-primary-100: 30 64 175;
--color-primary-200: 29 78 216;
--color-primary-300: 37 99 235;
--color-primary-400: 59 130 246;
--color-primary-500: 96 165 250;
--color-primary-600: 147 197 253;
--color-primary-700: 191 219 254;
--color-primary-800: 219 234 254;
--color-primary-900: 239 246 255;
--color-surface: 15 23 42;
--color-surface-hover: 30 41 59;
--color-surface-border: 51 65 85;
--color-text-primary: 248 250 252;
--color-text-secondary: 203 213 225;
--color-text-muted: 148 163 184;
--color-text-inverse: 15 23 42;
--color-accent-500: 56 189 248;
--color-accent-600: 14 165 233;
--color-success: 74 222 128;
--color-warning: 250 204 21;
--color-error: 248 113 113;
}
* {
@apply border-surface-border;
}
html {
@apply scroll-smooth;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
}
body {
@apply bg-surface text-text-primary antialiased;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
line-height: 1.7;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::selection {
@apply bg-primary-500/30 text-text-primary;
}
::-webkit-scrollbar {
@apply w-2 h-2;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-text-muted/30 rounded-full hover:bg-text-muted/50 transition-colors;
}
::-webkit-scrollbar-corner {
@apply bg-transparent;
}
/* Focus visible */
:focus-visible {
@apply outline-none ring-2 ring-primary-500 ring-offset-2 ring-offset-surface;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}
@layer components {
/* Buttons */
.btn {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl font-medium text-sm transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply btn bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800 shadow-sm hover:shadow-md;
}
.btn-secondary {
@apply btn bg-surface-hover text-text-primary border border-surface-border hover:bg-surface-border active:bg-surface-border/50;
}
.btn-ghost {
@apply btn bg-transparent text-text-secondary hover:bg-surface-hover hover:text-text-primary active:bg-surface-border;
}
.btn-danger {
@apply btn bg-error/10 text-error hover:bg-error/20 active:bg-error/30;
}
.btn-icon {
@apply btn p-2.5;
}
.btn-sm {
@apply px-3 py-1.5 text-xs gap-1.5;
}
.btn-lg {
@apply px-6 py-3 text-base gap-2;
}
/* Cards */
.card {
@apply bg-surface border border-surface-border rounded-2xl overflow-hidden transition-all duration-300 hover:shadow-lg hover:border-primary-500/20;
}
.card-hover {
@apply card hover:-translate-y-1;
}
.card-interactive {
@apply card-hover cursor-pointer;
}
/* Inputs */
.input {
@apply w-full px-4 py-2.5 rounded-xl bg-surface border border-surface-border text-text-primary placeholder:text-text-muted transition-all duration-200 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed;
}
.input-error {
@apply border-error focus:border-error focus:ring-error/20;
}
.label {
@apply block text-sm font-medium text-text-secondary mb-1.5;
}
.textarea {
@apply input min-h-[120px] resize-y;
}
.select {
@apply input appearance-none bg-no-repeat bg-right pr-10;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-position: right 0.75rem center;
}
/* Badges */
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-primary {
@apply badge bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300;
}
.badge-success {
@apply badge bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300;
}
.badge-warning {
@apply badge bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300;
}
.badge-error {
@apply badge bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300;
}
.badge-neutral {
@apply badge bg-surface-border text-text-secondary;
}
/* Layout */
.container {
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
}
.section {
@apply py-16 sm:py-24 lg:py-32;
}
.section-sm {
@apply py-10 sm:py-16;
}
/* Typography */
.heading-1 {
@apply text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight text-text-primary;
}
.heading-2 {
@apply text-3xl sm:text-4xl font-bold tracking-tight text-text-primary;
}
.heading-3 {
@apply text-2xl sm:text-3xl font-semibold tracking-tight text-text-primary;
}
.heading-4 {
@apply text-xl sm:text-2xl font-semibold tracking-tight text-text-primary;
}
.text-body {
@apply text-base text-text-secondary leading-relaxed;
}
.text-body-lg {
@apply text-lg text-text-secondary leading-relaxed;
}
.text-sm {
@apply text-sm text-text-muted;
}
.text-xs {
@apply text-xs text-text-muted;
}
.link {
@apply text-primary-600 hover:text-primary-700 underline-offset-2 hover:underline transition-colors;
}
/* Animations */
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
.animate-slide-up {
animation: slideUp 0.4s ease-out;
}
.animate-slide-down {
animation: slideDown 0.3s ease-out;
}
.animate-scale-in {
animation: scaleIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-16px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
/* Stagger animations */
.stagger-1 { animation-delay: 50ms; }
.stagger-2 { animation-delay: 100ms; }
.stagger-3 { animation-delay: 150ms; }
.stagger-4 { animation-delay: 200ms; }
.stagger-5 { animation-delay: 250ms; }
.stagger-6 { animation-delay: 300ms; }
/* Glass morphism */
.glass {
@apply bg-white/70 dark:bg-slate-900/70 backdrop-blur-xl border border-white/20 dark:border-slate-700/50;
}
.glass-strong {
@apply bg-white/80 dark:bg-slate-900/80 backdrop-blur-2xl border border-white/30 dark:border-slate-700/50;
}
/* Gradient text */
.gradient-text {
@apply bg-gradient-to-r from-primary-600 via-primary-500 to-accent-500 bg-clip-text text-transparent;
}
/* Divider */
.divider {
@apply h-px bg-surface-border border-none my-6;
}
.divider-vertical {
@apply w-px h-full bg-surface-border border-none mx-6;
}
/* Tooltip */
.tooltip {
@apply relative inline-block;
}
.tooltip::before {
@apply absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 text-xs text-white bg-slate-900 rounded opacity-0 invisible transition-all duration-200 whitespace-nowrap;
content: attr(data-tooltip);
}
.tooltip:hover::before {
@apply opacity-100 visible translate-y-0;
}
/* Loading skeleton */
.skeleton {
@apply animate-pulse bg-surface-border rounded;
}
.skeleton-text {
@apply skeleton h-4 w-full;
}
.skeleton-title {
@apply skeleton h-6 w-3/4;
}
.skeleton-avatar {
@apply skeleton rounded-full;
}
.skeleton-card {
@apply skeleton rounded-2xl;
}
/* Article content */
.prose {
@apply text-text-secondary leading-relaxed;
}
.prose h1 { @apply text-3xl font-bold text-text-primary mt-10 mb-4 pb-2 border-b border-surface-border; }
.prose h2 { @apply text-2xl font-bold text-text-primary mt-10 mb-4; }
.prose h3 { @apply text-xl font-semibold text-text-primary mt-8 mb-3; }
.prose h4 { @apply text-lg font-semibold text-text-primary mt-6 mb-2; }
.prose p { @apply mb-6 leading-relaxed; }
.prose a { @apply text-primary-600 hover:text-primary-700 underline-offset-2 hover:underline; }
.prose strong { @apply font-semibold text-text-primary; }
.prose em { @apply italic; }
.prose code { @apply px-1.5 py-0.5 bg-surface-border rounded text-sm font-mono text-primary-600; }
.prose pre { @apply bg-slate-900 rounded-xl p-4 overflow-x-auto my-6; }
.prose pre code { @apply bg-transparent p-0 text-slate-100; }
.prose ul { @apply list-disc list-inside mb-6 space-y-2; }
.prose ol { @apply list-decimal list-inside mb-6 space-y-2; }
.prose li { @apply leading-relaxed; }
.prose blockquote { @apply border-l-4 border-primary-500 pl-4 italic text-text-muted my-6; }
.prose img { @apply rounded-xl my-6 max-w-full h-auto; }
.prose video { @apply rounded-xl my-6 max-w-full; }
.prose audio { @apply w-full my-4; }
.prose table { @apply w-full border-collapse my-6; }
.prose th, .prose td { @apply px-4 py-2 border border-surface-border text-left; }
.prose th { @apply bg-surface-hover font-semibold; }
.prose hr { @apply my-8 border-surface-border; }
.prose kbd { @apply px-2 py-1 bg-surface-border rounded text-sm font-mono; }
/* Music player */
.music-player {
@apply fixed bottom-4 right-4 z-50 glass-strong rounded-2xl shadow-xl border border-white/20 dark:border-slate-700/50 transition-all duration-300;
}
.music-player.minimized {
@apply bottom-4 right-4 w-16 h-16 p-0;
}
/* Admin layout */
.admin-sidebar {
@apply fixed inset-y-0 left-0 z-40 w-64 bg-surface border-r border-surface-border transform transition-transform duration-300 lg:translate-x-0 -translate-x-full;
}
.admin-sidebar.open {
@apply translate-x-0;
}
.admin-header {
@apply fixed top-0 left-0 right-0 z-30 h-16 bg-surface/80 backdrop-blur-xl border-b border-surface-border lg:left-64;
}
.admin-main {
@apply pt-16 lg:pl-64 min-h-screen transition-all duration-300;
}
/* Table */
.table-container {
@apply overflow-x-auto rounded-xl border border-surface-border;
}
.table {
@apply w-full text-sm;
}
.table th {
@apply px-4 py-3 text-left font-semibold text-text-secondary bg-surface-hover border-b border-surface-border;
}
.table td {
@apply px-4 py-3 border-b border-surface-border/50;
}
.table tr:last-child td {
@apply border-b-0;
}
.table tbody tr {
@apply transition-colors hover:bg-surface-hover;
}
/* Dropdown */
.dropdown {
@apply absolute right-0 top-full mt-2 w-48 bg-surface border border-surface-border rounded-xl shadow-lg py-1 opacity-0 invisible transform translate-y-2 transition-all duration-200 z-50;
}
.dropdown.open {
@apply opacity-100 visible translate-y-0;
}
.dropdown-item {
@apply flex items-center gap-2 px-4 py-2 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary transition-colors;
}
.dropdown-divider {
@apply h-px bg-surface-border my-1;
}
/* Modal */
.modal-overlay {
@apply fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4;
}
.modal {
@apply bg-surface rounded-2xl shadow-xl max-h-[90vh] overflow-hidden w-full max-w-2xl transform transition-all;
}
.modal-lg {
@apply max-w-4xl;
}
.modal-xl {
@apply max-w-6xl;
}
/* Toast */
.toast {
@apply fixed bottom-4 right-4 z-50 flex items-center gap-3 px-4 py-3 bg-surface border border-surface-border rounded-xl shadow-xl transform transition-all duration-300;
}
.toast.success {
@apply border-green-500/50 bg-green-500/10;
}
.toast.error {
@apply border-red-500/50 bg-red-500/10;
}
.toast.warning {
@apply border-yellow-500/50 bg-yellow-500/10;
}
.toast.info {
@apply border-primary-500/50 bg-primary-500/10;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
.text-pretty {
text-wrap: pretty;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.gradient-border {
@apply relative;
background: linear-gradient(to right, var(--color-surface), var(--color-surface)) padding-box,
linear-gradient(to right, rgb(var(--color-primary-500)), rgb(var(--color-accent-500))) border-box;
border: 1px solid transparent;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.shimmer {
@apply relative overflow-hidden;
background: linear-gradient(
90deg,
rgb(var(--color-surface-border)) 25%,
rgb(var(--color-surface-hover)) 50%,
rgb(var(--color-surface-border)) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.dark .shimmer {
background: linear-gradient(
90deg,
rgb(var(--color-surface-border)) 25%,
rgb(var(--color-surface-hover)) 50%,
rgb(var(--color-surface-border)) 75%
);
}
}
+98
View File
@@ -0,0 +1,98 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
950: '#082f49',
},
dark: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
950: '#020617',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
display: ['Space Grotesk', 'system-ui', 'sans-serif'],
},
animation: {
'fade-in': 'fadeIn 0.5s ease-out',
'slide-up': 'slideUp 0.5s ease-out',
'slide-down': 'slideDown 0.3s ease-out',
'scale-in': 'scaleIn 0.2s ease-out',
'spin-slow': 'spin 3s linear infinite',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'bounce-slow': 'bounce 2s infinite',
'float': 'float 6s ease-in-out infinite',
'gradient': 'gradient 8s ease infinite',
'shimmer': 'shimmer 2s infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(20px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
slideDown: {
'0%': { opacity: '0', transform: 'translateY(-10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
scaleIn: {
'0%': { opacity: '0', transform: 'scale(0.95)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
float: {
'0%, 100%': { transform: 'translateY(0px)' },
'50%': { transform: 'translateY(-10px)' },
},
gradient: {
'0%, 100%': { 'background-position': '0% 50%' },
'50%': { 'background-position': '100% 50%' },
},
shimmer: {
'0%': { 'background-position': '-200% 0' },
'100%': { 'background-position': '200% 0' },
},
},
backgroundSize: {
'300%': '300%',
},
boxShadow: {
'glow': '0 0 20px rgba(14, 165, 233, 0.3)',
'glow-lg': '0 0 40px rgba(14, 165, 233, 0.4)',
'inner-glow': 'inset 0 0 20px rgba(14, 165, 233, 0.1)',
},
backdropBlur: {
xs: '2px',
},
},
},
plugins: [],
}
+32
View File
@@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@views/*": ["src/views/*"],
"@stores/*": ["src/stores/*"],
"@composables/*": ["src/composables/*"],
"@utils/*": ["src/utils/*"],
"@assets/*": ["src/assets/*"]
},
"types": ["vite/client", "vue", "vue-router"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+58
View File
@@ -0,0 +1,58 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
vue(),
tailwindcss(),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'@components': resolve(__dirname, 'src/components'),
'@views': resolve(__dirname, 'src/views'),
'@stores': resolve(__dirname, 'src/stores'),
'@composables': resolve(__dirname, 'src/composables'),
'@utils': resolve(__dirname, 'src/utils'),
'@assets': resolve(__dirname, 'src/assets'),
},
},
server: {
port: 3000,
host: true,
proxy: {
'/api': {
target: 'http://127.0.0.1:8090',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
'/_/': {
target: 'http://127.0.0.1:8090',
changeOrigin: true,
ws: true,
},
},
},
build: {
outDir: '../pb_public',
emptyOutDir: true,
sourcemap: false,
minify: 'esbuild',
rollupOptions: {
output: {
manualChunks: {
'vendor': ['vue', 'vue-router', 'pinia', 'pocketbase'],
'tiptap': ['@tiptap/vue-3', '@tiptap/starter-kit', '@tiptap/extension-image', '@tiptap/extension-video', '@tiptap/extension-audio', '@tiptap/extension-code-block-lowlight'],
'ui': ['@heroicons/vue', 'nprogress'],
'utils': ['date-fns', 'markdown-it'],
},
},
},
},
define: {
__APP_VERSION__: JSON.stringify(process.env.npm_package_version),
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),
},
})