feat: Phase 1 - PocketBase 迁移文件、种子数据、部署脚本
- apps/pb_migrations/1710000001_init_collections.js: 9个集合完整定义 - apps/pb_migrations/1710000002_seed_data.js: 默认管理员、分类、标签、页面、示例文章 - scripts/install.sh: Linux 一键部署脚本 (systemd, Nginx, SSL, 防火墙, Fail2Ban) - scripts/install.ps1: Windows 一键部署脚本 (NSSM服务, 计划任务, Certbot) - scripts/start.sh: 服务启动脚本 - scripts/backup.sh: 数据备份脚本 - scripts/update.sh: 代码更新部署脚本 - docs/DEPLOY.md: 详细部署教程文档
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
|
||||
// 1. 创建分类集合
|
||||
const categoriesCollection = new Collection({
|
||||
name: 'categories',
|
||||
type: 'base',
|
||||
system: false,
|
||||
schema: [
|
||||
{ name: 'name', type: 'text', required: true, options: { max: 50 } },
|
||||
{ name: 'slug', type: 'text', required: true, unique: true, options: { max: 60, pattern: '^[a-z0-9-]+$' } },
|
||||
{ name: 'description', type: 'editor', options: { convertHtml: false } },
|
||||
{ name: 'icon', type: 'text', options: { max: 100 } },
|
||||
{ name: 'color', type: 'text', options: { max: 7, pattern: '^#[0-9A-Fa-f]{6}$' } },
|
||||
{ name: 'sort_order', type: 'number', required: true, default: '0' },
|
||||
{ name: 'parent', type: 'relation', options: { collectionId: '', cascadeDelete: false, minSelect: 0, maxSelect: 1, displayFields: ['name'] } },
|
||||
{ name: 'is_active', type: 'bool', required: true, default: 'true' },
|
||||
{ name: 'seo_title', type: 'text', options: { max: 60 } },
|
||||
{ name: 'seo_description', type: 'text', options: { max: 160 } },
|
||||
{ name: 'seo_keywords', type: 'text', options: { max: 200 } },
|
||||
],
|
||||
indexes: [
|
||||
'CREATE UNIQUE INDEX idx_categories_slug ON categories (slug)',
|
||||
'CREATE INDEX idx_categories_parent ON categories (parent)',
|
||||
'CREATE INDEX idx_categories_sort ON categories (sort_order)',
|
||||
],
|
||||
listRule: 'is_active = true',
|
||||
viewRule: 'is_active = true',
|
||||
createRule: '@request.auth.role = "admin"',
|
||||
updateRule: '@request.auth.role = "admin"',
|
||||
deleteRule: '@request.auth.role = "admin"',
|
||||
options: {},
|
||||
})
|
||||
|
||||
// 自引用修复
|
||||
categoriesCollection.schema.find(f => f.name === 'parent')!.options.collectionId = categoriesCollection.id
|
||||
|
||||
dao.saveCollection(categoriesCollection)
|
||||
|
||||
// 2. 创建标签集合
|
||||
const tagsCollection = new Collection({
|
||||
name: 'tags',
|
||||
type: 'base',
|
||||
system: false,
|
||||
schema: [
|
||||
{ name: 'name', type: 'text', required: true, unique: true, options: { max: 30 } },
|
||||
{ name: 'slug', type: 'text', required: true, unique: true, options: { max: 40, pattern: '^[a-z0-9-]+$' } },
|
||||
{ name: 'color', type: 'text', required: true, default: '#0ea5e9', options: { max: 7, pattern: '^#[0-9A-Fa-f]{6}$' } },
|
||||
{ name: 'description', type: 'editor', options: { convertHtml: false } },
|
||||
{ name: 'sort_order', type: 'number', required: true, default: '0' },
|
||||
{ name: 'is_active', type: 'bool', required: true, default: 'true' },
|
||||
],
|
||||
indexes: [
|
||||
'CREATE UNIQUE INDEX idx_tags_slug ON tags (slug)',
|
||||
'CREATE INDEX idx_tags_sort ON tags (sort_order)',
|
||||
],
|
||||
listRule: 'is_active = true',
|
||||
viewRule: 'is_active = true',
|
||||
createRule: '@request.auth.role = "admin"',
|
||||
updateRule: '@request.auth.role = "admin"',
|
||||
deleteRule: '@request.auth.role = "admin"',
|
||||
options: {},
|
||||
})
|
||||
|
||||
dao.saveCollection(tagsCollection)
|
||||
|
||||
// 3. 创建文章集合
|
||||
const postsCollection = new Collection({
|
||||
name: 'posts',
|
||||
type: 'base',
|
||||
system: false,
|
||||
schema: [
|
||||
{ name: 'title', type: 'text', required: true, options: { max: 200 } },
|
||||
{ name: 'slug', type: 'text', required: true, unique: true, options: { max: 220, pattern: '^[a-z0-9-]+$' } },
|
||||
{ name: 'excerpt', type: 'editor', options: { convertHtml: false } },
|
||||
{ name: 'content', type: 'json', required: true }, // TipTap JSON 格式
|
||||
{ name: 'content_html', type: 'text' }, // 渲染后的 HTML
|
||||
{ name: 'cover', type: 'file', options: { maxSelect: 1, maxSize: 10485760, mimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif'] } },
|
||||
{ name: 'gallery', type: 'file', options: { maxSelect: 20, maxSize: 10485760, mimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif'] } },
|
||||
{ name: 'video', type: 'file', options: { maxSelect: 1, maxSize: 524288000, mimeTypes: ['video/mp4', 'video/webm', 'video/ogg'] } },
|
||||
{ name: 'audio', type: 'file', options: { maxSelect: 1, maxSize: 104857600, mimeTypes: ['audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/mp3', 'audio/aac'] } },
|
||||
{ name: 'category', type: 'relation', required: false, options: { collectionId: '', cascadeDelete: false, minSelect: 0, maxSelect: 1, displayFields: ['name', 'slug'] } },
|
||||
{ name: 'tags', type: 'relation', required: false, options: { collectionId: '', cascadeDelete: false, minSelect: 0, maxSelect: 20, displayFields: ['name', 'slug', 'color'] } },
|
||||
{ name: 'author', type: 'relation', required: true, options: { collectionId: '_pb_users_auth_', cascadeDelete: false, minSelect: 1, maxSelect: 1, displayFields: ['name', 'email'] } },
|
||||
{ name: 'published', type: 'bool', required: true, default: 'false' },
|
||||
{ name: 'published_at', type: 'date', required: false },
|
||||
{ name: 'featured', type: 'bool', required: true, default: 'false' },
|
||||
{ name: 'pinned', type: 'bool', required: true, default: 'false' },
|
||||
{ name: 'allow_comment', type: 'bool', required: true, default: 'true' },
|
||||
{ name: 'views', type: 'number', required: true, default: '0' },
|
||||
{ name: 'likes', type: 'number', required: true, default: '0' },
|
||||
{ name: 'reading_time', type: 'number', required: true, default: '0' }, // 分钟
|
||||
{ name: 'seo_title', type: 'text', options: { max: 60 } },
|
||||
{ name: 'seo_description', type: 'text', options: { max: 160 } },
|
||||
{ name: 'seo_keywords', type: 'text', options: { max: 200 } },
|
||||
{ name: 'canonical_url', type: 'url', options: { max: 500 } },
|
||||
{ name: 'template', type: 'text', options: { max: 50 } }, // 自定义模板
|
||||
{ name: 'meta', type: 'json' }, // 扩展字段
|
||||
],
|
||||
indexes: [
|
||||
'CREATE UNIQUE INDEX idx_posts_slug ON posts (slug)',
|
||||
'CREATE INDEX idx_posts_category ON posts (category)',
|
||||
'CREATE INDEX idx_posts_published ON posts (published, published_at)',
|
||||
'CREATE INDEX idx_posts_featured ON posts (featured, published_at)',
|
||||
'CREATE INDEX idx_posts_author ON posts (author)',
|
||||
'CREATE INDEX idx_posts_created ON posts (created)',
|
||||
],
|
||||
listRule: 'published = true',
|
||||
viewRule: 'published = true || @request.auth.role = "admin" || @request.auth.id = author',
|
||||
createRule: '@request.auth.role = "admin"',
|
||||
updateRule: '@request.auth.role = "admin" || @request.auth.id = author',
|
||||
deleteRule: '@request.auth.role = "admin"',
|
||||
options: {},
|
||||
})
|
||||
|
||||
// 设置关联
|
||||
postsCollection.schema.find(f => f.name === 'category')!.options.collectionId = categoriesCollection.id
|
||||
postsCollection.schema.find(f => f.name === 'tags')!.options.collectionId = tagsCollection.id
|
||||
|
||||
dao.saveCollection(postsCollection)
|
||||
|
||||
// 4. 创建页面集合 (用于单页文档、关于我们、搭建教程等)
|
||||
const pagesCollection = new Collection({
|
||||
name: 'pages',
|
||||
type: 'base',
|
||||
system: false,
|
||||
schema: [
|
||||
{ name: 'title', type: 'text', required: true, options: { max: 200 } },
|
||||
{ name: 'slug', type: 'text', required: true, unique: true, options: { max: 100, pattern: '^[a-z0-9-]+$' } },
|
||||
{ name: 'content', type: 'json', required: true }, // TipTap JSON
|
||||
{ name: 'content_html', type: 'text' },
|
||||
{ name: 'cover', type: 'file', options: { maxSelect: 1, maxSize: 10485760, mimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif'] } },
|
||||
{ name: 'template', type: 'text', required: true, default: 'default', options: { max: 50 } }, // default, fullwidth, sidebar, doc
|
||||
{ name: 'published', type: 'bool', required: true, default: 'false' },
|
||||
{ name: 'sort_order', type: 'number', required: true, default: '0' },
|
||||
{ name: 'show_in_nav', type: 'bool', required: true, default: 'false' },
|
||||
{ name: 'nav_label', type: 'text', options: { max: 30 } },
|
||||
{ name: 'seo_title', type: 'text', options: { max: 60 } },
|
||||
{ name: 'seo_description', type: 'text', options: { max: 160 } },
|
||||
{ name: 'seo_keywords', type: 'text', options: { max: 200 } },
|
||||
{ name: 'meta', type: 'json' },
|
||||
],
|
||||
indexes: [
|
||||
'CREATE UNIQUE INDEX idx_pages_slug ON pages (slug)',
|
||||
'CREATE INDEX idx_pages_published ON pages (published, sort_order)',
|
||||
],
|
||||
listRule: 'published = true',
|
||||
viewRule: 'published = true || @request.auth.role = "admin"',
|
||||
createRule: '@request.auth.role = "admin"',
|
||||
updateRule: '@request.auth.role = "admin"',
|
||||
deleteRule: '@request.auth.role = "admin"',
|
||||
options: {},
|
||||
})
|
||||
|
||||
dao.saveCollection(pagesCollection)
|
||||
|
||||
// 5. 创建站点设置集合 (单例)
|
||||
const settingsCollection = new Collection({
|
||||
name: 'site_settings',
|
||||
type: 'base',
|
||||
system: false,
|
||||
schema: [
|
||||
// 基础信息
|
||||
{ name: 'site_name', type: 'text', required: true, default: '云升数码', options: { max: 100 } },
|
||||
{ name: 'site_subtitle', type: 'text', options: { max: 200 } },
|
||||
{ name: 'site_description', type: 'editor', options: { convertHtml: false } },
|
||||
{ name: 'site_keywords', type: 'text', options: { max: 500 } },
|
||||
{ name: 'site_url', type: 'url', options: { max: 500 } },
|
||||
|
||||
// Logo & Favicon
|
||||
{ name: 'logo', type: 'file', options: { maxSelect: 1, maxSize: 2097152, mimeTypes: ['image/svg+xml', 'image/png', 'image/webp'] } },
|
||||
{ name: 'logo_dark', type: 'file', options: { maxSelect: 1, maxSize: 2097152, mimeTypes: ['image/svg+xml', 'image/png', 'image/webp'] } },
|
||||
{ name: 'favicon', type: 'file', options: { maxSelect: 1, maxSize: 1048576, mimeTypes: ['image/svg+xml', 'image/png', 'image/x-icon'] } },
|
||||
{ name: 'apple_touch_icon', type: 'file', options: { maxSelect: 1, maxSize: 1048576, mimeTypes: ['image/png'] } },
|
||||
|
||||
// 背景音乐
|
||||
{ name: 'background_music_enabled', type: 'bool', required: true, default: 'true' },
|
||||
{ name: 'background_music_type', type: 'select', required: true, default: 'local', options: { maxSelect: 1, values: ['local', 'netease', 'tencent', 'xiami', 'kugou', 'kuwo'] } },
|
||||
{ name: 'background_music_file', type: 'file', options: { maxSelect: 1, maxSize: 52428800, mimeTypes: ['audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/mp3', 'audio/aac'] } },
|
||||
{ name: 'background_music_url', type: 'url', options: { max: 500 } },
|
||||
{ name: 'background_music_title', type: 'text', options: { max: 100 } },
|
||||
{ name: 'background_music_artist', type: 'text', options: { max: 100 } },
|
||||
{ name: 'background_music_cover', type: 'file', options: { maxSelect: 1, maxSize: 2097152, mimeTypes: ['image/jpeg', 'image/png', 'image/webp'] } },
|
||||
{ name: 'background_music_volume', type: 'number', required: true, default: '0.5', options: { min: 0, max: 1, step: 0.1 } },
|
||||
{ name: 'background_music_autoplay', type: 'bool', required: true, default: 'false' },
|
||||
{ name: 'background_music_loop', type: 'bool', required: true, default: 'true' },
|
||||
|
||||
// Footer
|
||||
{ name: 'footer_text', type: 'text', options: { max: 500 } },
|
||||
{ name: 'footer_copyright', type: 'text', options: { max: 200 } },
|
||||
{ name: 'icp_number', type: 'text', options: { max: 50 } },
|
||||
{ name: 'police_number', type: 'text', options: { max: 50 } },
|
||||
|
||||
// 社交链接
|
||||
{ name: 'social_links', type: 'json' }, // { github, twitter, bilibili, weibo, zhihu, email, rss }
|
||||
|
||||
// SEO & Analytics
|
||||
{ name: 'google_analytics_id', type: 'text', options: { max: 50 } },
|
||||
{ name: 'baidu_analytics_id', type: 'text', options: { max: 50 } },
|
||||
{ name: 'custom_head_code', type: 'editor', options: { convertHtml: false } },
|
||||
{ name: 'custom_body_code', type: 'editor', options: { convertHtml: false } },
|
||||
|
||||
// 评论系统
|
||||
{ name: 'comment_enabled', type: 'bool', required: true, default: 'false' },
|
||||
{ name: 'comment_system', type: 'select', required: true, default: 'giscus', options: { maxSelect: 1, values: ['giscus', 'waline', 'twikoo', 'cusdis', 'disqus'] } },
|
||||
{ name: 'comment_config', type: 'json' },
|
||||
|
||||
// 功能开关
|
||||
{ name: 'maintenance_mode', type: 'bool', required: true, default: 'false' },
|
||||
{ name: 'maintenance_message', type: 'editor', options: { convertHtml: false } },
|
||||
{ name: 'registration_enabled', type: 'bool', required: true, default: 'false' },
|
||||
{ name: 'rss_enabled', type: 'bool', required: true, default: 'true' },
|
||||
{ name: 'search_enabled', type: 'bool', required: true, default: 'true' },
|
||||
|
||||
// 缓存 & 性能
|
||||
{ name: 'cache_ttl', type: 'number', required: true, default: '3600' },
|
||||
{ name: 'image_optimization', type: 'bool', required: true, default: 'true' },
|
||||
{ name: 'cdn_url', type: 'url', options: { max: 500 } },
|
||||
],
|
||||
indexes: [],
|
||||
listRule: '@request.auth.role = "admin"',
|
||||
viewRule: '@request.auth.role = "admin" || true', // 公开读取部分字段
|
||||
createRule: '@request.auth.role = "admin"',
|
||||
updateRule: '@request.auth.role = "admin"',
|
||||
deleteRule: '@request.auth.role = "admin"',
|
||||
options: {
|
||||
// 单例模式:只能有一条记录
|
||||
},
|
||||
})
|
||||
|
||||
dao.saveCollection(settingsCollection)
|
||||
|
||||
// 6. 创建媒体集合 (扩展 PocketBase 默认文件管理)
|
||||
const mediaCollection = new Collection({
|
||||
name: 'media',
|
||||
type: 'base',
|
||||
system: false,
|
||||
schema: [
|
||||
{ name: 'file', type: 'file', required: true, options: { maxSelect: 1, maxSize: 104857600 } },
|
||||
{ name: 'title', type: 'text', options: { max: 200 } },
|
||||
{ name: 'alt', type: 'text', options: { max: 200 } },
|
||||
{ name: 'description', type: 'editor', options: { convertHtml: false } },
|
||||
{ name: 'folder', type: 'text', options: { max: 100 } },
|
||||
{ name: 'tags', type: 'relation', options: { collectionId: '', cascadeDelete: false, minSelect: 0, maxSelect: 20, displayFields: ['name'] } },
|
||||
{ name: 'metadata', type: 'json' }, // width, height, duration, bitrate, exif 等
|
||||
{ name: 'uploaded_by', type: 'relation', required: true, options: { collectionId: '_pb_users_auth_', cascadeDelete: false, minSelect: 1, maxSelect: 1, displayFields: ['name', 'email'] } },
|
||||
],
|
||||
indexes: [
|
||||
'CREATE INDEX idx_media_folder ON media (folder)',
|
||||
'CREATE INDEX idx_media_uploaded_by ON media (uploaded_by)',
|
||||
'CREATE INDEX idx_media_created ON media (created)',
|
||||
],
|
||||
listRule: '@request.auth.role = "admin"',
|
||||
viewRule: '@request.auth.role = "admin"',
|
||||
createRule: '@request.auth.role = "admin"',
|
||||
updateRule: '@request.auth.role = "admin"',
|
||||
deleteRule: '@request.auth.role = "admin"',
|
||||
options: {},
|
||||
})
|
||||
|
||||
mediaCollection.schema.find(f => f.name === 'tags')!.options.collectionId = tagsCollection.id
|
||||
|
||||
dao.saveCollection(mediaCollection)
|
||||
|
||||
// 7. 创建菜单集合
|
||||
const menusCollection = new Collection({
|
||||
name: 'menus',
|
||||
type: 'base',
|
||||
system: false,
|
||||
schema: [
|
||||
{ name: 'name', type: 'text', required: true, options: { max: 50 } },
|
||||
{ name: 'location', type: 'select', required: true, default: 'header', options: { maxSelect: 1, values: ['header', 'footer', 'mobile', 'sidebar'] } },
|
||||
{ name: 'items', type: 'json', required: true }, // [{ label, url, target, children[], icon, badge, order }]
|
||||
{ name: 'is_active', type: 'bool', required: true, default: 'true' },
|
||||
],
|
||||
indexes: [
|
||||
'CREATE UNIQUE INDEX idx_menus_location ON menus (location)',
|
||||
],
|
||||
listRule: 'is_active = true',
|
||||
viewRule: 'is_active = true',
|
||||
createRule: '@request.auth.role = "admin"',
|
||||
updateRule: '@request.auth.role = "admin"',
|
||||
deleteRule: '@request.auth.role = "admin"',
|
||||
options: {},
|
||||
})
|
||||
|
||||
dao.saveCollection(menusCollection)
|
||||
|
||||
// 8. 创建重定向集合 (SEO 友好)
|
||||
const redirectsCollection = new Collection({
|
||||
name: 'redirects',
|
||||
type: 'base',
|
||||
system: false,
|
||||
schema: [
|
||||
{ name: 'from_path', type: 'text', required: true, unique: true, options: { max: 500 } },
|
||||
{ name: 'to_path', type: 'text', required: true, options: { max: 500 } },
|
||||
{ name: 'status_code', type: 'select', required: true, default: '301', options: { maxSelect: 1, values: ['301', '302', '307', '308'] } },
|
||||
{ name: 'is_active', type: 'bool', required: true, default: 'true' },
|
||||
{ name: 'hits', type: 'number', required: true, default: '0' },
|
||||
],
|
||||
indexes: [
|
||||
'CREATE UNIQUE INDEX idx_redirects_from ON redirects (from_path)',
|
||||
],
|
||||
listRule: '@request.auth.role = "admin"',
|
||||
viewRule: '@request.auth.role = "admin"',
|
||||
createRule: '@request.auth.role = "admin"',
|
||||
updateRule: '@request.auth.role = "admin"',
|
||||
deleteRule: '@request.auth.role = "admin"',
|
||||
options: {},
|
||||
})
|
||||
|
||||
dao.saveCollection(redirectsCollection)
|
||||
|
||||
// 9. 创建通知/公告集合
|
||||
const noticesCollection = new Collection({
|
||||
name: 'notices',
|
||||
type: 'base',
|
||||
system: false,
|
||||
schema: [
|
||||
{ name: 'title', type: 'text', required: true, options: { max: 100 } },
|
||||
{ name: 'content', type: 'editor', required: true, options: { convertHtml: false } },
|
||||
{ name: 'type', type: 'select', required: true, default: 'info', options: { maxSelect: 1, values: ['info', 'success', 'warning', 'error', 'announcement'] } },
|
||||
{ name: 'position', type: 'select', required: true, default: 'top', options: { maxSelect: 1, values: ['top', 'bottom', 'center', 'top-right', 'top-left'] } },
|
||||
{ name: 'show_close', type: 'bool', required: true, default: 'true' },
|
||||
{ name: 'auto_close', type: 'number', required: true, default: '0' }, // 0 = 不自动关闭,单位毫秒
|
||||
{ name: 'link', type: 'url', options: { max: 500 } },
|
||||
{ name: 'link_text', type: 'text', options: { max: 30 } },
|
||||
{ name: 'start_at', type: 'date', required: false },
|
||||
{ name: 'end_at', type: 'date', required: false },
|
||||
{ name: 'is_active', type: 'bool', required: true, default: 'true' },
|
||||
{ name: 'target_roles', type: 'json' }, // ['admin', 'user', 'guest']
|
||||
{ name: 'dismissible', type: 'bool', required: true, default: 'true' },
|
||||
],
|
||||
indexes: [
|
||||
'CREATE INDEX idx_notices_active ON notices (is_active, start_at, end_at)',
|
||||
],
|
||||
listRule: 'is_active = true && (start_at = "" || start_at <= @now) && (end_at = "" || end_at >= @now)',
|
||||
viewRule: 'is_active = true && (start_at = "" || start_at <= @now) && (end_at = "" || end_at >= @now)',
|
||||
createRule: '@request.auth.role = "admin"',
|
||||
updateRule: '@request.auth.role = "admin"',
|
||||
deleteRule: '@request.auth.role = "admin"',
|
||||
options: {},
|
||||
})
|
||||
|
||||
dao.saveCollection(noticesCollection)
|
||||
})
|
||||
Reference in New Issue
Block a user