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