commit 928c62475af3b8e516f9d72d748ee657c3c54aa3 Author: plerr <1690532276@qq.com> Date: Fri Jul 24 14:36:10 2026 +0800 feat: 初始化云升数码展示页面项目 - Vue 3 + PocketBase 全栈架构 diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..f680091 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + + + +
+ + + \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..91abdf9 --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} \ No newline at end of file diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue new file mode 100644 index 0000000..f022141 --- /dev/null +++ b/apps/web/src/App.vue @@ -0,0 +1,57 @@ + + + + + \ No newline at end of file diff --git a/apps/web/src/composables/usePocketBase.ts b/apps/web/src/composables/usePocketBase.ts new file mode 100644 index 0000000..33f639a --- /dev/null +++ b/apps/web/src/composables/usePocketBase.ts @@ -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( + 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(page, perPage, { + filter, + sort, + expand, + fields, + }) + } + + // 获取单条记录 + async function getOne(collection: string, id: string, expand = '') { + return pb.collection(collection).getOne(id, { expand }) + } + + // 获取完整列表(不分页) + async function getFullList(collection: string, options: { filter?: string; sort?: string; expand?: string } = {}) { + return pb.collection(collection).getFullList({ + sort: '-created', + ...options, + }) + } + + // 创建记录 + async function create(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(formData) + } + + // 更新记录 + async function update(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(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 + }, + } +} \ No newline at end of file diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts new file mode 100644 index 0000000..0ca4f4f --- /dev/null +++ b/apps/web/src/main.ts @@ -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' \ No newline at end of file diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts new file mode 100644 index 0000000..24df654 --- /dev/null +++ b/apps/web/src/router/index.ts @@ -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 \ No newline at end of file diff --git a/apps/web/src/stores/auth.ts b/apps/web/src/stores/auth.ts new file mode 100644 index 0000000..f9f4571 --- /dev/null +++ b/apps/web/src/stores/auth.ts @@ -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(null) + const isLoading = ref(false) + const error = ref(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, + } +}) \ No newline at end of file diff --git a/apps/web/src/styles/main.css b/apps/web/src/styles/main.css new file mode 100644 index 0000000..e79fc02 --- /dev/null +++ b/apps/web/src/styles/main.css @@ -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% + ); + } +} \ No newline at end of file diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js new file mode 100644 index 0000000..46dcf08 --- /dev/null +++ b/apps/web/tailwind.config.js @@ -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: [], +} \ No newline at end of file diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..451e9ea --- /dev/null +++ b/apps/web/tsconfig.json @@ -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" }] +} \ No newline at end of file diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..950e12b --- /dev/null +++ b/apps/web/vite.config.ts @@ -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()), + }, +}) \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..dd58bc8 --- /dev/null +++ b/package.json @@ -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" +} \ No newline at end of file