feat: 初始化云升数码展示页面项目 - Vue 3 + PocketBase 全栈架构
This commit is contained in:
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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%
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -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()),
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "yunsheng-digital",
|
||||
"version": "1.0.0",
|
||||
"description": "云升数码 - 基于 PocketBase + Vue 3 的现代化数码展示平台",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter web dev",
|
||||
"build": "pnpm --filter web build",
|
||||
"preview": "pnpm --filter web preview",
|
||||
"pb:start": "pnpm --filter pb_migrations start",
|
||||
"pb:migrate": "pnpm --filter pb_migrations migrate",
|
||||
"db:reset": "pnpm --filter pb_migrations reset",
|
||||
"install:all": "pnpm install && pnpm --filter web install && pnpm --filter pb_migrations install",
|
||||
"lint": "pnpm --filter web lint",
|
||||
"format": "pnpm --filter web format",
|
||||
"deploy": "pnpm build && pnpm --filter web deploy"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"typescript": "^5.3.3",
|
||||
"eslint": "^8.56.0",
|
||||
"prettier": "^3.2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@9.0.0"
|
||||
}
|
||||
Reference in New Issue
Block a user