feat: 初始化慧遇书院官网一期

This commit is contained in:
2026-08-12 18:01:17 +08:00
commit 5d31ee5743
101 changed files with 17259 additions and 0 deletions

24
server/utils/audit.ts Normal file
View File

@@ -0,0 +1,24 @@
import type { H3Event } from 'h3'
import { useDatabase } from './db'
export function writeAuditLog(event: H3Event, input: {
operatorId?: number
action: string
entityType: string
entityKey?: string
metadata?: Record<string, unknown>
}) {
const db = useDatabase()
db.prepare(`
INSERT INTO audit_logs (operator_id, action, entity_type, entity_key, ip_address, metadata_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
input.operatorId ?? null,
input.action,
input.entityType,
input.entityKey ?? null,
getRequestIP(event, { xForwardedFor: true }) ?? null,
JSON.stringify(input.metadata ?? {}),
new Date().toISOString(),
)
}

34
server/utils/auth.ts Normal file
View File

@@ -0,0 +1,34 @@
import type { H3Event } from 'h3'
export interface SessionUser {
id: number
username: string
displayName: string
}
export async function getAdminSession(event: H3Event) {
const config = useRuntimeConfig()
const password = config.sessionPassword || (import.meta.dev ? 'dev-session-password-change-before-production-2026' : '')
if (password.length < 32) {
throw new Error('NUXT_SESSION_PASSWORD must contain at least 32 characters')
}
return useSession<{ user?: SessionUser }>(event, {
name: 'huiyu_admin_session',
password,
maxAge: 60 * 60 * 8,
cookie: {
httpOnly: true,
secure: config.sessionCookieSecure,
sameSite: 'lax',
path: '/',
},
})
}
export async function requireAdmin(event: H3Event) {
const session = await getAdminSession(event)
if (!session.data.user) {
throw createError({ statusCode: 401, statusMessage: '请先登录' })
}
return session.data.user
}

119
server/utils/db.ts Normal file
View File

@@ -0,0 +1,119 @@
import Database from 'better-sqlite3'
import { hashSync } from 'bcryptjs'
import { createHash } from 'node:crypto'
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { defaultSiteDocument, siteDocumentSchema } from './site-schema'
let database: Database.Database | undefined
const now = () => new Date().toISOString()
export function useDatabase() {
if (database) return database
const config = useRuntimeConfig()
const databasePath = resolve(process.cwd(), config.databasePath)
mkdirSync(dirname(databasePath), { recursive: true })
database = new Database(databasePath)
database.pragma('journal_mode = WAL')
database.pragma('foreign_keys = ON')
database.pragma('busy_timeout = 5000')
database.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
filename TEXT PRIMARY KEY,
checksum TEXT NOT NULL,
applied_at TEXT NOT NULL
)
`)
const migrationDir = resolve(process.cwd(), 'server/database/migrations')
const migrations = readdirSync(migrationDir).filter(name => name.endsWith('.sql')).sort().map((filename) => {
const sql = readFileSync(resolve(migrationDir, filename), 'utf8')
return { filename, sql, checksum: createHash('sha256').update(sql).digest('hex') }
})
const applied = new Map((database.prepare('SELECT filename, checksum FROM schema_migrations').all() as Array<{ filename: string; checksum: string }>).map(row => [row.filename, row.checksum]))
for (const migration of migrations) {
const recordedChecksum = applied.get(migration.filename)
if (recordedChecksum && recordedChecksum !== migration.checksum) throw new Error(`Migration ${migration.filename} was modified after being applied`)
}
const pending = migrations.filter(migration => !applied.has(migration.filename))
if (pending.length && existsSync(databasePath) && statSync(databasePath).size > 0) {
database.pragma('wal_checkpoint(TRUNCATE)')
copyFileSync(databasePath, `${databasePath}.pre-migration-${Date.now()}.bak`)
}
const applyMigration = database.transaction((migration: typeof migrations[number]) => {
database!.exec(migration.sql)
database!.prepare('INSERT INTO schema_migrations (filename, checksum, applied_at) VALUES (?, ?, ?)').run(migration.filename, migration.checksum, now())
})
pending.forEach(migration => applyMigration(migration))
seedInitialData(database)
return database
}
function seedInitialData(db: Database.Database) {
const timestamp = now()
const siteExists = db.prepare('SELECT 1 FROM site_documents WHERE document_key = ?').get('website')
if (!siteExists) {
const json = JSON.stringify(defaultSiteDocument)
db.prepare(`
INSERT INTO site_documents (document_key, draft_json, published_json, updated_at, published_at)
VALUES (?, ?, ?, ?, ?)
`).run('website', json, json, timestamp, timestamp)
}
const userCount = db.prepare('SELECT COUNT(*) AS count FROM admin_users').get() as { count: number }
if (userCount.count === 0) {
const config = useRuntimeConfig()
const password = config.initialAdminPassword || (import.meta.dev ? 'DevOnly-ChangeMe-2026!' : '')
if (!password) {
throw new Error('NUXT_INITIAL_ADMIN_PASSWORD must be set before the first production start')
}
db.prepare(`
INSERT INTO admin_users (username, password_hash, display_name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`).run(config.initialAdminUsername, hashSync(password, 12), '超级管理员', timestamp, timestamp)
}
}
export function readSiteDocument(mode: 'draft' | 'published' = 'published') {
const db = useDatabase()
const row = db.prepare('SELECT draft_json, published_json, updated_at, published_at FROM site_documents WHERE document_key = ?')
.get('website') as { draft_json: string; published_json: string; updated_at: string; published_at: string }
return {
data: siteDocumentSchema.parse(normalizeLegacyDocument(JSON.parse(mode === 'draft' ? row.draft_json : row.published_json))),
updatedAt: row.updated_at,
publishedAt: row.published_at,
}
}
function normalizeLegacyDocument(input: unknown) {
if (!input || typeof input !== 'object') return input
const document = structuredClone(input) as Record<string, any>
if (Array.isArray(document.products)) {
document.products = document.products.map((product: Record<string, any>, index: number) => {
const fallback = defaultSiteDocument.products[index]
const slug = product.slug || fallback?.slug || `product-${index + 1}`
return {
slug,
title: product.title || fallback?.title || `产品 ${index + 1}`,
subtitle: product.subtitle || '',
type: product.type || fallback?.type || 'course',
summary: product.summary || fallback?.summary || '',
content: product.content || fallback?.content || '',
image: product.image || fallback?.image || '/images/hero-bg-v3.webp',
href: product.href || `/products/${slug}`,
status: product.status || 'published',
}
})
}
document.articles ??= structuredClone(defaultSiteDocument.articles)
document.team ??= structuredClone(defaultSiteDocument.team)
document.channels ??= structuredClone(defaultSiteDocument.channels)
document.legal ??= structuredClone(defaultSiteDocument.legal)
document.brand = { ...defaultSiteDocument.brand, ...(document.brand || {}) }
document.teacher = { ...defaultSiteDocument.teacher, ...(document.teacher || {}) }
return document
}

81
server/utils/media.ts Normal file
View File

@@ -0,0 +1,81 @@
import type { Database } from 'better-sqlite3'
import { access, mkdir, rename } from 'node:fs/promises'
import { dirname, resolve, sep } from 'node:path'
export interface MediaAssetRow {
id: number
asset_key: string
original_name: string
mime_type: string
extension: string
bytes: number
width: number
height: number
original_path: string
derived_path: string
thumbnail_path: string
alt_text: string
source_text: string
copyright_text: string
authorization_status: 'pending' | 'authorized' | 'restricted'
status: 'active' | 'trash' | 'deleted'
created_at: string
updated_at: string
deleted_at: string | null
}
export const mediaUrl = (relativePath: string) => `/api/media/${relativePath}`
export function serializeMedia(row: MediaAssetRow, referenced = false) {
return {
id: row.id,
key: row.asset_key,
originalName: row.original_name,
mimeType: row.mime_type,
bytes: row.bytes,
width: row.width,
height: row.height,
url: mediaUrl(row.derived_path),
thumbnailUrl: mediaUrl(row.thumbnail_path),
altText: row.alt_text,
source: row.source_text,
copyright: row.copyright_text,
authorizationStatus: row.authorization_status,
status: row.status,
referenced,
createdAt: row.created_at,
deletedAt: row.deleted_at,
}
}
export function isMediaReferenced(db: Database, row: MediaAssetRow) {
const document = db.prepare('SELECT draft_json, published_json FROM site_documents WHERE document_key = ?').get('website') as { draft_json: string; published_json: string }
const needles = [mediaUrl(row.original_path), mediaUrl(row.derived_path), mediaUrl(row.thumbnail_path)]
return needles.some(path => document.draft_json.includes(path) || document.published_json.includes(path))
}
export function uploadsRoot() { return resolve(process.cwd(), 'data', 'uploads') }
export function safeUploadPath(relativePath: string) {
const root = uploadsRoot()
const fullPath = resolve(root, relativePath)
if (!fullPath.startsWith(`${root}${sep}`)) throw createError({ statusCode: 400, statusMessage: '无效的文件路径' })
return fullPath
}
export async function moveMediaFiles(row: MediaAssetRow, target: 'trash' | 'restore') {
const paths = [row.original_path, row.derived_path, row.thumbnail_path]
for (const current of paths) {
const sourceRelative = target === 'trash' ? current : current.replace(/^trash\//, '')
const destinationRelative = target === 'trash' ? `trash/${current}` : current.replace(/^trash\//, '')
const source = safeUploadPath(target === 'trash' ? sourceRelative : `trash/${sourceRelative}`)
const destination = safeUploadPath(destinationRelative)
try {
await access(source)
await mkdir(dirname(destination), { recursive: true })
await rename(source, destination)
} catch (error: any) {
if (error?.code !== 'ENOENT') throw error
}
}
}

205
server/utils/site-schema.ts Normal file
View File

@@ -0,0 +1,205 @@
import { z } from 'zod'
const localOrHttpUrl = z.string().max(300).refine(value => !value || value.startsWith('/') || /^https?:\/\//i.test(value), '请输入站内路径或以 http(s) 开头的完整链接')
const httpUrl = z.string().max(300).refine(value => !value || /^https?:\/\//i.test(value), '请输入以 http(s) 开头的完整链接')
const metricSchema = z.object({
value: z.string().max(20),
label: z.string().max(30),
description: z.string().max(80),
})
const productSchema = z.object({
slug: z.string().regex(/^[a-z0-9-]+$/).max(80),
title: z.string().max(60),
subtitle: z.string().max(80),
type: z.enum(['course', 'service', 'tool']).default('course'),
summary: z.string().max(300).default(''),
content: z.string().max(6000).default(''),
image: localOrHttpUrl,
href: localOrHttpUrl,
status: z.enum(['draft', 'published', 'offline']).default('draft'),
})
const articleSchema = z.object({
slug: z.string().regex(/^[a-z0-9-]+$/).max(100),
type: z.enum(['news', 'statement', 'media']),
title: z.string().min(1).max(120),
summary: z.string().max(300),
content: z.string().max(12000),
cover: localOrHttpUrl,
publishedAt: z.string().max(40),
status: z.enum(['draft', 'published', 'offline']),
statementNo: z.string().max(60).default(''),
sourceName: z.string().max(80).default(''),
sourceUrl: httpUrl.default(''),
})
const teamMemberSchema = z.object({
slug: z.string().regex(/^[a-z0-9-]+$/).max(80),
name: z.string().min(1).max(40),
role: z.string().max(80),
summary: z.string().max(300),
avatar: localOrHttpUrl,
specialties: z.array(z.string().max(30)).max(8),
status: z.enum(['draft', 'published', 'offline']),
})
const channelSchema = z.object({
name: z.string().min(1).max(40),
account: z.string().max(80),
url: httpUrl,
qrCode: localOrHttpUrl,
})
const siteDocumentBaseSchema = z.object({
brand: z.object({
name: z.string().min(1).max(40),
englishName: z.string().max(80),
logo: localOrHttpUrl,
slogan: z.string().min(1).max(40),
subSlogan: z.string().max(80),
companyName: z.string().max(100).default('待后台配置'),
creditCode: z.string().max(40).default('待后台配置'),
introduction: z.string().max(500).default(''),
copyright: z.string().max(100).default('© 2026 慧遇书院 版权所有'),
icp: z.string().max(80).default('备案信息待配置'),
policeRecord: z.string().max(80).default(''),
}),
hero: z.object({
titleLine1: z.string().min(1).max(20),
titleLine2: z.string().min(1).max(20),
subtitle: z.string().max(80),
cta: z.string().max(20),
backgroundImage: localOrHttpUrl,
}),
about: z.object({
title: z.string().max(40),
summary: z.string().max(300),
metrics: z.array(metricSchema).min(1).max(4),
}),
teacher: z.object({
name: z.string().max(30),
title: z.string().max(80),
summary: z.string().max(400),
portrait: localOrHttpUrl,
tags: z.array(z.string().max(20)).max(6),
fullBio: z.string().max(8000).default(''),
experiences: z.array(z.object({ year: z.string().max(20), title: z.string().max(100), description: z.string().max(300) })).max(20).default([]),
achievements: z.array(z.string().max(120)).max(12).default([]),
externalLinks: z.array(z.object({ label: z.string().max(40), url: httpUrl })).max(10).default([]),
}),
products: z.array(productSchema).max(8),
articles: z.array(articleSchema).max(60).default([]),
team: z.array(teamMemberSchema).max(30).default([]),
channels: z.array(channelSchema).max(12).default([]),
contact: z.object({
phone: z.string().max(30),
email: z.string().max(100),
address: z.string().max(160),
}),
seo: z.object({
title: z.string().max(70),
description: z.string().max(180),
}),
legal: z.object({
privacyTitle: z.string().max(80).default('隐私政策'),
privacyContent: z.string().max(12000).default(''),
termsTitle: z.string().max(80).default('服务条款'),
termsContent: z.string().max(12000).default(''),
}).default({ privacyTitle: '隐私政策', privacyContent: '', termsTitle: '服务条款', termsContent: '' }),
})
export const siteDocumentSchema = siteDocumentBaseSchema.superRefine((document, context) => {
const ensureUnique = (items: Array<{ slug: string }>, field: 'products' | 'articles' | 'team') => {
const seen = new Set<string>()
items.forEach((item, index) => {
if (seen.has(item.slug)) context.addIssue({ code: 'custom', path: [field, index, 'slug'], message: `英文标识“${item.slug}”重复` })
seen.add(item.slug)
})
}
ensureUnique(document.products, 'products')
ensureUnique(document.articles, 'articles')
ensureUnique(document.team, 'team')
})
export type SiteDocument = z.infer<typeof siteDocumentSchema>
export const defaultSiteDocument: SiteDocument = {
brand: {
name: '慧遇书院',
englishName: 'HUIYU ACADEMY',
logo: '/images/brand-mark-v2.png',
slogan: '身心疗愈 · 科技赋能',
subSlogan: '用科技与专业,陪伴每一次心灵的成长',
companyName: '慧遇书院',
creditCode: '待后台配置',
introduction: '慧遇书院专注于家庭教育、心理成长与长期成长实践。当前公司主体信息为演示内容,正式上线前需完成核验与替换。',
copyright: '© 2026 慧遇书院 版权所有',
icp: 'ICP备案信息待配置',
policeRecord: '',
},
hero: {
titleLine1: '身心疗愈',
titleLine2: '科技赋能',
subtitle: '用科技与专业,陪伴每一次心灵的成长',
cta: '了解慧遇书院',
backgroundImage: '/images/hero-bg-v3.webp',
},
about: {
title: '关于慧遇书院',
summary: '慧遇书院专注于身心疗愈与成长领域,以专业内容与长期实践陪伴心灵成长。',
metrics: [
{ value: '身心', label: '专注领域', description: '疗愈与成长' },
{ value: '10万+', label: '陪伴学员', description: '持续连接与支持' },
{ value: '2018', label: '成立时间', description: '长期主义实践' },
],
},
teacher: {
name: '卢慧老师',
title: '家庭教育与心理学研究者|慧遇书院创始人',
summary: '长期从事家庭教育与心理成长实践,探索东方智慧与现代心理学在家庭场景中的融合应用。',
portrait: '/images/lu-hui-portrait.jpg',
tags: ['家庭教育', '心理成长', '关系支持', '东方智慧'],
fullBio: '卢慧老师长期从事家庭教育与心理成长实践,关注个体在家庭关系、亲密关系与自我成长中的真实需要。\n\n她持续探索东方智慧与现代心理学在日常生活中的融合应用并通过课程、陪伴与内容分享帮助更多人建立稳定、清晰、可持续的成长路径。\n\n以上为官网演示介绍正式发布前应以经本人确认的资料为准。',
experiences: [
{ year: '2024', title: '持续完善身心整合实践体系', description: '结合真实家庭场景,形成结构化的陪伴与成长方法。' },
{ year: '2021', title: '出版与内容传播', description: '围绕家庭教育、关系与自我成长开展内容分享。' },
{ year: '2018', title: '创立慧遇书院', description: '持续开展家庭教育与心理成长实践。' },
],
achievements: ['长期开展家庭教育与心理成长实践', '持续研发结构化陪伴与成长课程', '推动东方智慧与现代心理学融合应用'],
externalLinks: [{ label: '百度百科', url: 'https://baike.baidu.com/item/%E5%8D%A2%E6%85%A7/65761247' }],
},
products: [
{ slug: 'family-relationship', title: '家庭关系训练营', subtitle: '关注关系与代际议题', type: 'course', summary: '从真实家庭场景出发,理解关系互动背后的需要与模式。', content: '课程内容为演示信息,正式上线前需由运营人员在后台核验与替换。\n\n课程将围绕觉察、沟通和关系实践展开帮助参与者把理解转化为日常行动。', image: '/images/hero-bg-v3.webp', href: '/products/family-relationship', status: 'published' },
{ slug: 'human-wisdom', title: '人本智慧学', subtitle: '从觉知走向实践', type: 'course', summary: '以长期主义的方式理解自我、关系与成长。', content: '本页面为产品结构演示,具体课程体系、适用人群和服务安排待后台补充。', image: '/images/hero-bg-v3.webp', href: '/products/human-wisdom', status: 'published' },
{ slug: 'companion-assistant', title: '智能陪伴助手', subtitle: '科技赋能日常练习', type: 'tool', summary: '让日常觉察和练习更清晰、更容易坚持。', content: '该产品当前处于演示阶段,正式能力与使用入口以后台发布信息为准。', image: '/images/hero-bg-v3.webp', href: '/products/companion-assistant', status: 'published' },
],
articles: [
{ slug: 'official-information-notice', type: 'statement', title: '慧遇书院官方信息说明(演示)', summary: '用于说明官网、官方账号与对外信息的统一发布原则。', content: '本页面为官方声明结构演示。正式上线前,需要由运营人员替换为经审核的真实声明内容。', cover: '/images/hero-bg-v3.webp', publishedAt: '2026-08-10', status: 'published', statementNo: 'HUIYU-DEMO-001', sourceName: '', sourceUrl: '' },
{ slug: 'website-phase-one', type: 'news', title: '慧遇书院官网一期建设启动', summary: '官网将成为品牌、人物、课程与官方信息的统一可信入口。', content: '慧遇书院官网一期建设已经启动。首阶段将完成品牌信息、卢慧老师资料、课程产品、官方声明与团队介绍的结构化呈现。\n\n当前内容包含演示数据生产发布前将通过后台统一核验。', cover: '/images/hero-bg-v3.webp', publishedAt: '2026-08-10', status: 'published', statementNo: '', sourceName: '', sourceUrl: '' },
],
team: [
{ slug: 'course-consultant', name: '课程顾问', role: '成长路径与课程支持', summary: '为来访者提供清晰、克制的课程信息说明。演示成员,待后台替换。', avatar: '/images/lu-hui-portrait.jpg', specialties: ['课程咨询', '成长支持'], status: 'published' },
{ slug: 'content-editor', name: '内容老师', role: '品牌内容与知识整理', summary: '负责把专业内容整理成可信、易理解的表达。演示成员,待后台替换。', avatar: '/images/lu-hui-portrait.jpg', specialties: ['内容整理', '知识传播'], status: 'published' },
],
channels: [
{ name: '微信公众号', account: '账号待后台配置', url: '', qrCode: '' },
{ name: '视频号', account: '账号待后台配置', url: '', qrCode: '' },
],
contact: {
phone: '待后台配置',
email: '待后台配置',
address: '待后台配置',
},
seo: {
title: '慧遇书院|身心疗愈 专业陪伴',
description: '慧遇书院官方网站,关注家庭教育、心理成长与长期成长实践。',
},
legal: {
privacyTitle: '隐私政策',
privacyContent: '更新日期2026年8月10日\n\n本网站一期不提供用户注册、支付或在线咨询信息收集功能。访问过程中产生的必要技术日志仅用于保障网站安全与稳定。\n\n如后续增加表单、统计或第三方服务本政策将同步更新并在收集前说明用途、范围与保存期限。',
termsTitle: '服务条款',
termsContent: '本网站内容用于介绍慧遇书院、卢慧老师、课程产品与官方信息。\n\n演示或待替换内容不构成正式服务承诺正式课程与服务以经审核发布的页面及双方确认的协议为准。\n\n未经授权不得以误导方式转载、改编或冒用本网站品牌与人物资料。',
},
}