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

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
node_modules
.nuxt
.output
.git
.DS_Store
data/db/*
data/media/*
素材库
docs
*.log
.env*
public/images/hero-bg-v3-master.png
public/images/brand-logo-source.png
public/images/hero-bg-v1.png

9
.env.docker.example Normal file
View File

@@ -0,0 +1,9 @@
NUXT_SESSION_PASSWORD=replace-with-at-least-32-random-characters
NUXT_SESSION_COOKIE_SECURE=false
NUXT_INITIAL_ADMIN_USERNAME=admin
NUXT_INITIAL_ADMIN_PASSWORD=replace-with-a-strong-admin-password
APP_BIND_IP=127.0.0.1
APP_PORT=3000
IMAGE_TAG=2026.08.11-1
SITE_URL=https://example.com
BACKUP_HOST_DIR=./backups

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
NUXT_SESSION_PASSWORD=replace-with-at-least-32-random-characters
NUXT_SESSION_COOKIE_SECURE=false
NUXT_DATABASE_PATH=./data/db/huiyu.sqlite
NUXT_INITIAL_ADMIN_USERNAME=admin
NUXT_INITIAL_ADMIN_PASSWORD=replace-before-first-start
NUXT_PUBLIC_SITE_URL=http://localhost:3000

44
.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
.nuxt/
.output/
node_modules/
# 本地与生产环境变量(仅提交脱敏模板)
.env
.env.*
!.env.example
!.env.docker.example
# 日志、测试输出与编辑器文件
*.log
coverage/
.idea/
.vscode/
*.swp
*.swo
# 私钥、证书及本地凭据
*.key
*.pem
*.p12
*.pfx
*.crt
*.cer
*.kdbx
data/db/*.sqlite*
data/uploads/original/*
data/uploads/derived/*
data/uploads/trash/*
backups/*
!backups/.gitkeep
!data/uploads/**/.gitkeep
npm-debug.log*
.DS_Store
# 部署不需要的大文件夹
artifacts/
素材库/
# 设计母版仅本地留档;线上使用 AVIF/WebP 压缩版本
public/images/hero-bg-v3-master.png
public/images/brand-logo-source.png
public/images/hero-bg-v1.png

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM node:22-bookworm-slim AS build
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm config set registry https://registry.npmmirror.com \
&& npm config set fetch-retries 5 \
&& npm config set fetch-retry-mintimeout 20000 \
&& npm config set fetch-retry-maxtimeout 120000 \
&& npm ci --legacy-peer-deps
COPY . .
RUN npm run build
FROM node:22-bookworm-slim AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/*
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=3000
WORKDIR /app
COPY --from=build /app/.output ./.output
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/server/database/migrations ./server/database/migrations
RUN mkdir -p /app/data/db /app/data/media /app/data/uploads/original /app/data/uploads/derived /app/data/uploads/trash /app/offsite-backups \
&& chown -R node:node /app
USER node
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

72
README.md Normal file
View File

@@ -0,0 +1,72 @@
# 慧愈科技官网一期
单体 Nuxt 4 全栈应用公开官网、结构化内容管理后台、SQLite、服务器本地图片存储。
## 一期功能范围
- 一屏一屏滚动展示的品牌首页,含公司、卢慧老师、课程产品、官方信息、团队与品牌动态。
- 卢慧老师、课程列表与详情、动态列表与详情、官方声明、陪伴团队、隐私政策和服务条款独立页面。
- 后台首页编辑、课程产品/动态声明/团队增删改查、公司主体/人物资料/官方渠道/法律文本配置。
- 素材中心支持图片像素校验、原图保留、WebP/缩略图生成、来源与授权记录、引用保护和回收站恢复。
- 草稿与发布版本分离、发布版本记录、审计日志、管理员登录与上传图片校验。
- 最近 20 个发布版本可恢复为草稿,管理员可在后台修改高强度密码。
- `sitemap.xml``robots.txt`、canonical、Open Graph 和首页组织机构 JSON-LD。
## 本机直接运行
```bash
npm install --legacy-peer-deps
npm run dev
```
后台入口:`/admin/login`。开发环境初始账号为 `admin`,初始密码为 `DevOnly-ChangeMe-2026!`
## Docker 测试环境
项目已附带仅供本机测试的 `.env.docker`。启动:
```bash
docker compose up -d --build
docker compose ps
```
本机测试后台账号为 `admin`,密码为 `LocalTest-Huiyu-2026!`。正式部署前必须替换 `.env.docker` 中的两个密码;该文件已被 Git 忽略。
```bash
docker compose logs -f web
docker compose down
```
数据库和上传图片保存在 Docker 命名卷 `huiyu_official_site_data`,重建容器不会丢失。不要使用 `docker compose down -v`,除非明确需要删除全部业务数据。
## 备份
容器运行时执行:
```bash
sh scripts/backup.sh
```
脚本使用 SQLite 在线备份 API同时打包上传素材加密后写入宿主机 `BACKUP_HOST_DIR`,并立即执行完整性校验。详细操作见 [部署与恢复手册](docs/部署与恢复手册.md)。
## 上线前最低检查
- 修改会话密钥和后台初始密码。
- 配置域名、HTTPS 和反向代理。
- 域名启用 HTTPS 后,把 `NUXT_SESSION_COOKIE_SECURE` 改为 `true`
- 配置每日备份及恢复演练。
-`.env.docker` 仅保存在服务器,不提交版本库。
- 验证后台发布、图片上传、首页移动端和桌面端展示。
- 在部署环境设置 `NUXT_PUBLIC_SITE_URL=https://正式域名`,用于 canonical、站点地图和 robots。
## 当前演示内容说明
数据库首次启动会写入清楚标注的演示课程、动态、声明和团队内容。正式上线前,请在后台逐项替换并由相关负责人核验;公司主体、联系方式、备案号和官方账号默认显示“待后台配置”。
需要重新写入完整演示数据时,在 Docker 测试环境启动后执行:
```bash
ADMIN_PASSWORD='本地后台密码' node scripts/seed-mock-content.mjs
```
脚本会通过后台 API 保存草稿并立即发布新版本。数据源在 `content/演示数据/一期官网演示数据.json`,人物资料依据已清洗的百度百科内容整理,其余公司、课程、团队、账号和联系信息均明确属于演示数据。

44
app/app.vue Normal file
View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
const route = useRoute()
const config = useRuntimeConfig()
const scrollProgress = ref(0)
let scrollFrame = 0
const canonical = computed(() => `${String(config.public.siteUrl).replace(/\/$/, '')}${route.path === '/' ? '/' : route.path}`)
const isDraftPreview = computed(() => route.query.preview === 'draft')
useHead(() => route.path.startsWith('/admin') ? {} : {
link: [{ rel: 'canonical', href: canonical.value }],
})
useSeoMeta({ ogUrl: () => canonical.value, twitterCard: 'summary_large_image' })
const updateScrollProgress = () => {
scrollFrame = 0
const max = document.documentElement.scrollHeight - window.innerHeight
scrollProgress.value = max > 0 ? Math.min(1, window.scrollY / max) : 0
}
const requestProgressUpdate = () => {
if (!scrollFrame) scrollFrame = requestAnimationFrame(updateScrollProgress)
}
onMounted(() => {
updateScrollProgress()
window.addEventListener('scroll', requestProgressUpdate, { passive: true })
window.addEventListener('resize', requestProgressUpdate, { passive: true })
})
onBeforeUnmount(() => {
if (import.meta.client) {
window.removeEventListener('scroll', requestProgressUpdate)
window.removeEventListener('resize', requestProgressUpdate)
}
if (scrollFrame) cancelAnimationFrame(scrollFrame)
})
</script>
<template>
<NuxtRouteAnnouncer />
<a class="skip-link" href="#main-content">跳到正文</a>
<div v-if="!route.path.startsWith('/admin')" class="page-progress" aria-hidden="true"><span :style="{ transform: `scaleX(${scrollProgress})` }" /></div>
<div v-if="isDraftPreview" class="draft-preview-ribbon"><strong>草稿预览</strong><span>此页面不会被搜索引擎收录</span><NuxtLink to="/admin">返回后台</NuxtLink></div>
<NuxtPage />
</template>

503
app/assets/css/main.scss Normal file
View File

@@ -0,0 +1,503 @@
:root {
--ink: #0b3152;
--ink-2: #234d70;
--muted: #667d92;
--line: rgba(36, 77, 112, 0.13);
--mist: #eef3f8;
--paper: #fbfcfd;
--warm: #f6eee5;
--white: #fff;
--shadow: 0 22px 70px rgba(42, 75, 104, 0.12);
--shadow-card: 0 16px 44px rgba(42, 75, 104, 0.1);
--section-space: clamp(104px, 8vw, 120px);
--serif: "Songti SC", "STSong", "Noto Serif SC", "Source Han Serif SC", serif;
--sans: Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; background: var(--paper); }
body { margin: 0; color: var(--ink); font-family: var(--sans); font-size: 16px; line-height: 1.65; background: var(--paper); -webkit-font-smoothing: antialiased; }
body.nav-open { overflow: hidden; }
a { color: inherit; text-decoration: none; }
button, input, textarea { font: inherit; }
button { color: inherit; }
img { max-width: 100%; }
:focus-visible { outline: 3px solid #2f70a6; outline-offset: 4px; }
.skip-link { position: fixed; z-index: 1000; top: 12px; left: 12px; padding: 10px 14px; border-radius: 9px; color: white; background: var(--ink); transform: translateY(-150%); transition: transform .2s ease; }
.skip-link:focus { transform: none; }
.page-progress { position: fixed; z-index: 100; top: 0; left: 0; width: 100%; height: 3px; pointer-events: none; }
.page-progress span { display: block; width: 100%; height: 100%; background: linear-gradient(90deg, #6f9dc1, #dcae79, #245a83); transform-origin: left; }
.draft-preview-ribbon { position: fixed; z-index: 90; right: 20px; bottom: 20px; display: flex; align-items: center; gap: 12px; padding: 12px 16px; border: 1px solid rgba(35,77,112,.16); border-radius: 14px; background: rgba(255,255,255,.94); box-shadow: var(--shadow); backdrop-filter: blur(16px); }
.draft-preview-ribbon span { color: var(--muted); font-size: 12px; }
.draft-preview-ribbon a { padding: 7px 10px; border-radius: 8px; color: white; background: var(--ink); font-size: 12px; }
.page-enter-active, .page-leave-active { transition: opacity .28s ease; }
.page-enter-from, .page-leave-to { opacity: 0; }
.eyebrow { margin: 0 0 14px; color: #607b92; font-size: 13px; line-height: 1.4; letter-spacing: .24em; text-transform: uppercase; }
.section-pad { padding: var(--section-space) max(6vw, 32px); }
.section-heading h2 { margin: 0; font: 500 clamp(36px, 4.1vw, 58px)/1.18 var(--serif); letter-spacing: .045em; }
.section-heading > p:last-child { color: var(--muted); font-size: 17px; line-height: 1.9; }
.section-heading.centered { max-width: 780px; margin: 0 auto 56px; text-align: center; }
.site-header { position: absolute; z-index: 20; top: 0; left: 0; display: grid; grid-template-columns: 250px 1fr 160px; align-items: center; width: 100%; padding: 26px 4.2vw; }
.main-nav { display: flex; justify-content: center; gap: clamp(24px, 3.2vw, 54px); }
.main-nav a { position: relative; display: inline-flex; align-items: center; min-height: 44px; padding: 0 4px; color: #17364e; font-size: 14px; white-space: nowrap; }
.main-nav a::after { position: absolute; right: 50%; bottom: -12px; left: 50%; height: 1px; content: ""; background: var(--ink); transition: .28s ease; }
.main-nav a:hover::after, .main-nav a:focus-visible::after, .main-nav a[aria-current="page"]::after { right: 0; left: 0; }
.header-cta, .primary-button { position: relative; display: inline-flex; align-items: center; justify-content: center; gap: 11px; overflow: hidden; border-radius: 999px; color: white; background: linear-gradient(135deg, #2b5f8b, #0a3456); box-shadow: 0 12px 28px rgba(10, 52, 86, .2); isolation: isolate; }
.header-cta::before, .primary-button::before { position: absolute; top: -80%; left: -45%; z-index: -1; width: 32%; height: 260%; content: ""; background: linear-gradient(90deg, transparent, rgba(255,255,255,.32), transparent); transform: rotate(20deg) translateX(-260%); transition: transform .65s cubic-bezier(.2,.7,.2,1); }
.header-cta:hover::before, .header-cta:focus-visible::before, .primary-button:hover::before, .primary-button:focus-visible::before { transform: rotate(20deg) translateX(620%); }
.header-cta { min-height: 46px; padding: 0 22px; font-size: 14px; }
.menu-toggle { display: none; border: 0; background: transparent; }
.hero { --hero-scroll: 0; --pointer-x: 0; --pointer-y: 0; position: relative; min-height: 81svh; overflow: hidden; background: #e8eef3; }
.hero-media { position: absolute; inset: -4%; display: block; }
.hero-media img { width: 100%; height: 100%; max-width: none; object-fit: cover; transform: translate3d(calc(var(--pointer-x) * -12px), calc(var(--hero-scroll) * 36px + var(--pointer-y) * -8px), 0) scale(1.035); transition: transform .18s linear; will-change: transform; }
.hero::after { position: absolute; inset: 0; z-index: 0; content: ""; pointer-events: none; background: linear-gradient(180deg, rgba(255,255,255,.03), transparent 65%, rgba(255,255,255,.28)); }
.hero-copy { position: relative; z-index: 2; width: min(720px, 72vw); padding: clamp(230px, 29vh, 280px) 0 120px 10.5vw; transform: translate3d(0, calc(var(--hero-scroll) * -38px), 0); animation: hero-in 1s cubic-bezier(.2,.7,.2,1) both; will-change: transform; }
.hero-copy h1 { display: flex; flex-direction: column; margin: 0 0 24px; font: 500 clamp(58px, 4.5vw, 74px)/1.08 var(--serif); letter-spacing: .055em; text-shadow: 0 2px 24px rgba(255,255,255,.36); }
.hero-copy h1 span { display: block; animation: title-rise .9s cubic-bezier(.16,.8,.25,1) both; }
.hero-copy h1 span:nth-child(2) { animation-delay: .12s; }
.hero-subtitle { margin: 0 0 38px; color: #244b69; font: 400 clamp(18px, 1.45vw, 24px)/1.75 var(--serif); letter-spacing: .085em; }
.primary-button { min-height: 52px; padding: 0 28px; transition: transform .25s ease, box-shadow .25s ease; }
.primary-button:hover { transform: translateY(-3px); box-shadow: 0 18px 34px rgba(10,52,86,.26); }
.scroll-cue { position: absolute; z-index: 3; bottom: 54px; left: 50%; display: flex; flex-direction: column; align-items: center; gap: 8px; color: rgba(12,49,80,.68); font-size: 11px; letter-spacing: .18em; transform: translateX(-50%); animation: float-cue 2.2s ease-in-out infinite; }
.hero-wave { position: absolute; z-index: 1; right: -5%; bottom: -82px; left: -5%; height: 142px; border-radius: 50% 50% 0 0/100% 100% 0 0; background: var(--paper); }
.reveal-section { opacity: .68; transform: translateY(24px); transition: opacity .65s ease, transform .65s cubic-bezier(.16,.8,.25,1); }
.reveal-section.visible { opacity: 1; transform: none; }
.reveal-section > * { opacity: .72; transform: translateY(16px); }
.reveal-section.visible > * { animation: section-rise .68s cubic-bezier(.16,.8,.25,1) both; }
.reveal-section.visible > *:nth-child(2) { animation-delay: .07s; }
.reveal-section.visible > *:nth-child(3) { animation-delay: .12s; }
.about.reveal-section { opacity: 1; transform: none; }
.about.reveal-section > * { opacity: 1; transform: none; }
.about { min-height: 100svh; padding-top: 54px; background: var(--paper); }
.about-layout { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(320px, .75fr); gap: 44px; max-width: 1260px; margin: 0 auto; }
.about-visual { min-height: 430px; overflow: hidden; border-radius: 34px; box-shadow: var(--shadow); }
.about-visual img { width: 100%; height: 100%; object-fit: cover; transform: scale(1.1); transition: transform 1.8s cubic-bezier(.16,.8,.25,1); }
.reveal-section.visible .about-visual img { transform: scale(1.025); }
.metric-grid { display: grid; grid-template-rows: repeat(3, 1fr); gap: 16px; }
.metric-card { display: grid; grid-template-columns: 52px 104px minmax(0, 1fr); grid-template-rows: 1fr 1fr; column-gap: 14px; align-items: center; padding: 24px 28px; border: 1px solid rgba(68,105,133,.11); border-radius: 24px; opacity: .72; background: rgba(239,244,249,.8); box-shadow: 0 14px 34px rgba(62,91,116,.1); transform: translateX(18px); transition: transform .35s ease, box-shadow .35s ease, border-color .35s ease; }
.reveal-section.visible .metric-card { animation: metric-in .75s cubic-bezier(.16,.8,.25,1) both; animation-delay: var(--delay); }
.metric-card:hover { transform: translateX(5px); }
.metric-card svg { grid-row: 1/3; align-self: center; color: #6689a7; }
.metric-card strong { grid-column: 2; grid-row: 1/3; align-self: center; white-space: nowrap; font: 500 27px/1.2 var(--serif); }
.metric-card span { grid-column: 3; grid-row: 1; align-self: end; font-size: 15px; line-height: 1.45; }
.metric-card small { grid-column: 3; grid-row: 2; align-self: start; margin-top: 5px; color: var(--muted); line-height: 1.45; }
.teacher-section { min-height: 100svh; background: linear-gradient(135deg, #edf3f8, #fff9f4); }
.teacher-card { display: grid; grid-template-columns: minmax(360px, .86fr) minmax(420px, 1.14fr); max-width: 1270px; min-height: 650px; margin: 0 auto; overflow: hidden; border: 1px solid rgba(255,255,255,.8); border-radius: 38px; background: rgba(255,255,255,.72); box-shadow: var(--shadow); clip-path: inset(0 100% 0 0 round 38px); }
.reveal-section.visible .teacher-card { animation: panel-wipe .72s cubic-bezier(.16,.8,.25,1) both; }
.teacher-portrait { position: relative; overflow: hidden; background: linear-gradient(180deg, #dfe9f1, #f8f1eb); }
.teacher-portrait::after { position: absolute; inset: 8% -30% auto 4%; height: 82%; border: 1px solid rgba(255,255,255,.7); border-radius: 50%; content: ""; }
.teacher-portrait img { position: relative; z-index: 1; width: 100%; height: 100%; object-fit: cover; object-position: center 18%; transform: scale(1.06); transition: transform 1.2s cubic-bezier(.16,.8,.25,1); }
.teacher-card:hover .teacher-portrait img { transform: scale(1.015); }
.teacher-copy { align-self: center; max-width: 680px; padding: 70px clamp(44px, 5vw, 82px); }
.teacher-copy h2 { margin: 0 0 12px; font: 500 clamp(44px, 4.1vw, 58px)/1.18 var(--serif); letter-spacing: .045em; }
.teacher-title { color: #365a77; font: 400 18px/1.7 var(--serif); }
.teacher-summary { max-width: 610px; margin: 26px 0 30px; color: #526e84; font-size: 16px; line-height: 1.95; }
.tag-list { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 34px; }
.tag-list span { padding: 9px 15px; border: 1px solid var(--line); border-radius: 999px; color: #456984; background: rgba(238,244,248,.8); font-size: 13px; }
.text-link { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 5px 2px; border-bottom: 1px solid #7892a7; transition: gap .25s ease, color .25s ease; }
.text-link:hover, .text-link:focus-visible { gap: 13px; color: #1d5d8e; }
.philosophy { min-height: 90svh; }
.philosophy-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; max-width: 1260px; margin: 0 auto; }
.philosophy-card { position: relative; min-height: 360px; padding: 46px; overflow: hidden; border-radius: 30px; background-image: linear-gradient(180deg, rgba(246,249,251,.38), rgba(229,237,245,.88)), url('/images/hero-bg-v3.webp'); background-position: center; background-size: 118%; box-shadow: 0 18px 46px rgba(54,84,110,.12); transition: transform .5s cubic-bezier(.16,.8,.25,1), background-size .8s ease, box-shadow .5s ease; }
.philosophy-card:nth-child(2) { background-position: 68% center; }
.philosophy-card:nth-child(3) { background-position: 20% center; }
.philosophy-card:hover { transform: translateY(-10px) rotateX(2deg); background-size: 108%; box-shadow: 0 28px 60px rgba(54,84,110,.18); }
.philosophy-index { position: absolute; top: 34px; right: 38px; color: rgba(11,49,82,.38); font: 500 13px var(--sans); letter-spacing: .18em; }
.philosophy-card h3 { margin: 90px 0 8px; font: 500 42px var(--serif); }
.philosophy-card p { color: #526f86; }
.products { background: linear-gradient(180deg, #f8fafc, #eef3f7); }
.split-heading { display: flex; align-items: end; justify-content: space-between; max-width: 1260px; margin: 0 auto 56px; }
.split-heading > p { max-width: 470px; margin: 0; }
.product-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; max-width: 1260px; margin: 0 auto; }
.product-card { display: grid; grid-template-columns: 1fr auto; overflow: hidden; border: 1px solid rgba(36,77,112,.06); border-radius: 26px; background: white; box-shadow: var(--shadow-card); transition: transform .38s cubic-bezier(.16,.8,.25,1), box-shadow .38s ease; }
.product-card:hover { transform: translateY(-9px); box-shadow: 0 28px 56px rgba(52,82,107,.16); }
.product-card img { grid-column: 1/3; width: 100%; height: 240px; object-fit: cover; transition: transform .75s cubic-bezier(.16,.8,.25,1); }
.product-card:hover img { transform: scale(1.045); }
.product-card div { padding: 25px 8px 28px 28px; }
.product-card h3 { margin: 0 0 8px; font: 500 24px var(--serif); }
.product-card p { margin: 0; color: var(--muted); }
.product-card > svg { align-self: center; margin-right: 25px; }
.official { min-height: 76svh; }
.official-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; max-width: 1260px; margin: 0 auto; }
.official-grid article { min-height: 230px; padding: 36px; border: 1px solid var(--line); border-radius: 26px; background: white; transition: transform .35s ease, border-color .35s ease, box-shadow .35s ease; }
.official-grid article:hover { border-color: rgba(36,77,112,.24); box-shadow: 0 20px 42px rgba(54,84,110,.1); transform: translateY(-6px); }
.official-grid article svg { color: #6e8faa; }
.official-grid h3 { margin: 46px 0 10px; font: 500 23px var(--serif); }
.official-grid p { color: var(--muted); font-size: 14px; }
.news-section { background: #eef3f7; }
.news-panel { display: grid; grid-template-columns: .9fr 1.1fr; max-width: 1260px; margin: 0 auto; overflow: hidden; border-radius: 34px; background: white; box-shadow: var(--shadow); }
.news-feature { min-height: 430px; padding: 72px 56px; background-image: linear-gradient(120deg, rgba(238,244,249,.9), rgba(255,249,244,.72)), url('/images/hero-bg-v3.webp'); background-size: cover; }
.news-feature h2 { margin: 0 0 24px; font: 500 48px var(--serif); }
.news-feature > p:last-child { max-width: 420px; color: var(--muted); line-height: 1.9; }
.news-list { display: flex; flex-direction: column; justify-content: center; padding: 38px 54px; }
.news-list a { display: grid; grid-template-columns: 1fr auto 20px; gap: 20px; align-items: center; padding: 24px 0; border-bottom: 1px solid var(--line); }
.news-list time { color: #8092a1; font-size: 12px; }
.site-footer { display: grid; grid-template-columns: 1fr 1fr; gap: 50px; padding: 72px max(6vw, 32px) 26px; background: #e3ebf2; }
.footer-brand p { max-width: 360px; color: var(--muted); line-height: 1.8; }
.footer-contact { display: grid; align-content: center; justify-content: end; }
.footer-contact p { display: flex; align-items: center; gap: 10px; margin: 7px 0; color: #48667e; }
.footer-meta { grid-column: 1/3; display: flex; justify-content: space-between; padding-top: 24px; border-top: 1px solid rgba(44,75,101,.13); color: #667f93; font-size: 13px; }
.footer-meta a { display: inline-flex; align-items: center; min-height: 40px; }
.filing-links { display: inline-flex; align-items: center; gap: 6px; }
.police-filing-link { gap: 5px; }
.disabled-channel { padding: 12px 18px; border: 1px dashed var(--line); border-radius: 999px; color: #7c91a3; cursor: not-allowed; }
.public-page-shell { min-height: 100svh; background: linear-gradient(180deg, #edf3f8 0, #fbfcfd 420px); }
.public-page-shell > .site-header { position: absolute; }
.public-page-main { min-height: 72svh; padding-top: 112px; }
.listing-hero { max-width: 1260px; margin: 0 auto; padding: 100px 32px 74px; text-align: center; }
.listing-hero h1, .detail-hero h1, .story-page h1, .legal-page h1 { margin: 0 0 20px; font: 500 clamp(48px, 5.2vw, 78px)/1.18 var(--serif); letter-spacing: .07em; }
.listing-hero > p:last-child { max-width: 620px; margin: 0 auto; color: var(--muted); font-size: 17px; line-height: 1.9; }
.listing-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; max-width: 1260px; margin: 0 auto; padding: 0 32px 120px; }
.listing-card { overflow: hidden; border-radius: 28px; background: white; box-shadow: var(--shadow); transition: transform .3s ease; }
.listing-card:hover { transform: translateY(-7px); }
.listing-card img { width: 100%; height: 250px; object-fit: cover; }
.listing-card > div { padding: 30px; }
.listing-card small, .article-list time { color: #7890a4; font-size: 12px; letter-spacing: .08em; }
.listing-card h2 { margin: 12px 0; font: 500 27px var(--serif); }
.listing-card p { min-height: 54px; color: var(--muted); line-height: 1.8; }
.listing-card span { display: inline-block; margin-top: 16px; color: #2e638f; font-size: 13px; }
.article-list, .statement-list { max-width: 1080px; margin: 0 auto; padding: 0 32px 120px; }
.article-list > a { display: grid; grid-template-columns: 230px 1fr 30px; gap: 30px; align-items: center; padding: 25px 0; border-bottom: 1px solid var(--line); }
.article-list img { width: 230px; height: 140px; border-radius: 20px; object-fit: cover; }
.article-list h2, .statement-list h2 { margin: 9px 0; font: 500 26px var(--serif); }
.article-list p, .statement-list p { margin: 0; color: var(--muted); line-height: 1.8; }
.statement-list > a { display: flex; align-items: center; justify-content: space-between; gap: 30px; padding: 34px; border-bottom: 1px solid var(--line); }
.statement-list small, .statement-list time { color: #7c91a3; }
.story-page { max-width: 1060px; margin: 0 auto; padding: 72px 32px 130px; }
.story-page header { text-align: center; }
.story-page header > p { color: var(--muted); }
.story-page header > img { width: 100%; max-height: 530px; margin-top: 48px; border-radius: 34px; object-fit: cover; box-shadow: var(--shadow); }
.story-body { max-width: 760px; margin: 58px auto 0; }
.detail-back { display: inline-flex; margin-bottom: 34px; color: #59758c; font-size: 13px; }
.related-content { max-width: 760px; margin: 90px auto 0; padding-top: 42px; border-top: 1px solid var(--line); }
.related-content h2 { margin: 0 0 20px; font: 500 34px var(--serif); }
.related-content a { display: flex; justify-content: space-between; gap: 24px; padding: 16px 0; border-bottom: 1px solid var(--line); }
.related-content time { color: #7b91a4; font-size: 12px; }
.story-body .lead { margin-bottom: 30px; color: #315a7a; font: 400 22px/1.8 var(--serif); }
.long-copy { color: #516b80; font-size: 16px; line-height: 2.15; white-space: pre-line; }
.story-body .primary-button { min-height: 50px; margin-top: 38px; padding: 0 28px; }
.statement-page { max-width: 900px; }
.statement-page header { padding: 66px 30px; border-radius: 34px; background: rgba(255,255,255,.7); box-shadow: var(--shadow); }
.detail-hero { display: grid; grid-template-columns: 1.05fr .95fr; align-items: center; max-width: 1260px; min-height: 620px; margin: 0 auto; padding: 54px 32px 80px; }
.detail-hero > div { padding-left: 5vw; }
.detail-hero > div > p:last-child { max-width: 540px; color: #4d6d87; font: 400 19px/1.8 var(--serif); }
.detail-hero img { justify-self: center; width: min(440px, 100%); height: 540px; border-radius: 220px 220px 30px 30px; object-fit: cover; object-position: top; box-shadow: var(--shadow); }
.detail-content { display: grid; grid-template-columns: 1.4fr .6fr; gap: 80px; max-width: 1120px; margin: 0 auto; padding: 90px 32px; }
.detail-content h2 { font: 500 42px var(--serif); }
.detail-content aside { padding: 34px; border-radius: 26px; background: #edf3f8; }
.detail-content aside h3 { margin: 0 0 20px; font: 500 20px var(--serif); }
.detail-content aside h3:not(:first-child) { margin-top: 34px; }
.detail-content li { margin: 12px 0; color: var(--muted); line-height: 1.7; }
.timeline-section { background: #eef3f7; }
.timeline-list { max-width: 880px; margin: 0 auto; }
.timeline-list article { display: grid; grid-template-columns: 110px 1fr; gap: 28px; padding: 27px 0; border-bottom: 1px solid var(--line); }
.timeline-list time { color: #4f7797; font-size: 22px; }
.timeline-list h3 { margin: 0 0 8px; font: 500 22px var(--serif); }
.timeline-list p { margin: 0; color: var(--muted); }
.authority-links { display: flex; align-items: center; justify-content: center; gap: 22px; padding: 70px 30px; }
.authority-links h2 { margin-right: 30px; font: 500 28px var(--serif); }
.authority-links a { padding: 12px 18px; border: 1px solid var(--line); border-radius: 999px; transition: transform .25s ease, border-color .25s ease, background .25s ease; }
.authority-links a:hover { border-color: #7899b2; background: #f2f6fa; transform: translateY(-2px); }
.team-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; max-width: 1100px; margin: 0 auto; padding: 0 32px 120px; }
.team-grid article { display: grid; grid-template-columns: 210px 1fr; gap: 30px; padding: 24px; border-radius: 28px; background: white; box-shadow: var(--shadow); }
.team-grid img { width: 210px; height: 290px; border-radius: 20px; object-fit: cover; object-position: top; }
.team-grid article > div { min-width: 0; }
.team-grid h2, .team-grid h3 { margin: 18px 0 8px; font: 500 28px var(--serif); }
.team-grid h2, .team-grid h3, .team-grid .role { word-break: keep-all; overflow-wrap: normal; }
.team-grid .role { color: #426987; }
.team-grid article > div > p:not(.role) { color: var(--muted); line-height: 1.8; }
.legal-page { max-width: 850px; margin: 0 auto; padding: 110px 32px 160px; }
.login-page { display: grid; grid-template-columns: 1.05fr .95fr; min-height: 100svh; padding: 3.8vw; background: url('/images/hero-bg-v3.webp') center/cover; }
.login-brand { display: flex; flex-direction: column; justify-content: space-between; padding: 8px 5vw 7vh 0; }
.login-brand h1 { margin: 0 0 22px; font: 500 clamp(58px, 5vw, 86px)/1.15 var(--serif); letter-spacing: .08em; }
.login-brand p { color: #4d6d87; font: 400 20px var(--serif); letter-spacing: .08em; }
.login-card { align-self: center; width: min(620px, 100%); padding: clamp(48px, 5vw, 78px); border: 1px solid rgba(255,255,255,.78); border-radius: 34px; background: rgba(255,255,255,.76); box-shadow: 0 28px 80px rgba(58,76,91,.17); backdrop-filter: blur(22px); }
.login-card h2 { margin: 0; font: 500 43px var(--serif); letter-spacing: .08em; }
.login-intro { margin: 14px 0 40px; color: var(--muted); }
.login-card form { display: grid; }
.login-card label { margin: 18px 0 9px; font-weight: 600; }
.field { display: flex; align-items: center; gap: 12px; height: 58px; padding: 0 17px; border: 1px solid #cbd7e1; border-radius: 13px; background: rgba(255,255,255,.78); }
.field:focus-within { border-color: #5d8fbb; box-shadow: 0 0 0 3px rgba(74,127,172,.1); }
.field svg { color: #6d87a0; flex: none; }
.field input { width: 100%; border: 0; outline: 0; background: transparent; }
.field button { display: inline-flex; border: 0; background: transparent; cursor: pointer; }
.form-error { margin: 14px 0 0; color: #a94b43; font-size: 13px; }
.login-submit { display: flex; align-items: center; justify-content: center; gap: 14px; height: 58px; margin-top: 30px; border: 0; border-radius: 13px; color: white; background: linear-gradient(135deg, #326992, #0b3557); box-shadow: 0 14px 28px rgba(19,58,91,.2); cursor: pointer; }
.login-submit:disabled { opacity: .66; cursor: wait; }
.security-note { margin: 28px 0 0; color: #607a90; font-size: 13px; text-align: center; }
.admin-shell { display: grid; grid-template-columns: 232px 1fr; min-height: 100svh; background: #f3f6f9; }
.admin-sidebar { position: sticky; top: 0; display: flex; flex-direction: column; height: 100svh; padding: 24px 18px; border-right: 1px solid var(--line); background: linear-gradient(180deg, #f9fbfd 60%, #f5eee7); }
.admin-sidebar nav { display: grid; gap: 8px; margin-top: 48px; }
.admin-sidebar nav a, .admin-sidebar > button { display: flex; align-items: center; gap: 12px; min-height: 46px; padding: 0 14px; border: 0; border-radius: 12px; background: transparent; font-size: 14px; }
.admin-sidebar nav a.active { color: #1f5a8b; background: #e3edf9; }
.admin-sidebar > button { margin-top: auto; cursor: pointer; }
.admin-main { min-width: 0; }
.admin-topbar { position: sticky; z-index: 10; top: 0; display: flex; align-items: center; justify-content: space-between; min-height: 88px; padding: 16px 28px; border-bottom: 1px solid var(--line); background: rgba(249,251,253,.9); backdrop-filter: blur(18px); }
.admin-topbar p { margin: 0 0 5px; font: 500 22px var(--serif); }
.admin-topbar small { color: var(--muted); }
.admin-actions { display: flex; align-items: center; gap: 10px; }
.admin-actions a, .admin-actions button { display: inline-flex; align-items: center; gap: 8px; min-height: 40px; padding: 0 15px; border: 1px solid #ccd8e2; border-radius: 9px; background: white; cursor: pointer; }
.admin-actions .publish-button { border-color: var(--ink); color: white; background: var(--ink); }
.save-message { display: inline-flex; align-items: center; gap: 6px; margin-right: 4px; color: #3f8557; font-size: 13px; }
.dirty-indicator { padding: 7px 10px; border-radius: 999px; color: #8b5d2b; background: #fff1df; font-size: 12px; }
.admin-loading { padding: 90px; color: var(--muted); text-align: center; }
.admin-workspace { display: grid; grid-template-columns: minmax(540px, 1fr) minmax(430px, .9fr); gap: 20px; padding: 22px; }
.editor-panel, .preview-panel { border: 1px solid rgba(29,64,91,.08); border-radius: 20px; background: white; box-shadow: 0 12px 32px rgba(44,72,96,.06); }
.editor-panel { padding: 28px; }
.panel-heading { display: flex; align-items: center; justify-content: space-between; padding-bottom: 18px; border-bottom: 1px solid var(--line); }
.panel-heading.secondary { margin-top: 42px; }
.panel-heading > div { display: flex; align-items: center; gap: 12px; }
.panel-heading span { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 10px; color: #3d6e97; background: #e6f0fa; font-size: 12px; }
.panel-heading h1, .panel-heading h2 { margin: 0; font: 500 20px var(--serif); }
.panel-heading small { color: var(--muted); }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; padding-top: 22px; }
.form-grid label { display: grid; gap: 8px; color: #38566e; font-size: 13px; }
.form-grid label.full { grid-column: 1/3; }
.form-grid input, .form-grid textarea { width: 100%; padding: 12px 13px; border: 1px solid #d5dfe7; border-radius: 9px; outline: 0; color: #1c3c56; background: #fbfcfd; resize: vertical; }
.form-grid input:focus, .form-grid textarea:focus { border-color: #6d96b7; box-shadow: 0 0 0 3px rgba(78,128,168,.08); }
.image-input { position: relative; display: grid; grid-template-columns: 116px 1fr; align-items: center; min-height: 82px; overflow: hidden; border: 1px dashed #b8c8d6; border-radius: 12px; background: #f8fafc; cursor: pointer; }
.image-input img { width: 116px; height: 82px; padding: 8px; object-fit: contain; background: white; }
.image-input.portrait img { object-fit: cover; object-position: top; }
.image-input input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.image-input b { justify-self: center; color: #3d6e97; font-size: 13px; font-weight: 600; }
.media-placeholder { display: flex; align-items: center; gap: 18px; margin-top: 38px; padding: 24px; border: 1px dashed #b8c8d6; border-radius: 14px; color: #53738d; background: #f8fafc; }
.media-placeholder strong { display: block; margin-bottom: 5px; }
.media-placeholder p { margin: 0; color: var(--muted); font-size: 13px; }
.preview-panel { position: sticky; top: 109px; align-self: start; padding: 18px; }
.preview-label { display: flex; justify-content: space-between; padding: 4px 4px 16px; font-size: 14px; }
.preview-label small { color: var(--muted); }
.preview-window { min-height: 390px; padding: 22px; overflow: hidden; border-radius: 16px; background-position: center; background-size: cover; }
.preview-window .brand-logo img { width: 38px; height: 38px; }
.preview-window .brand-logo-copy strong { font-size: 17px; }
.preview-window .brand-logo-copy small { font-size: 6px; }
.preview-window > div:last-child { margin: 82px 0 0 8%; }
.preview-window h2 { margin: 0 0 12px; font: 500 44px/1.17 var(--serif); }
.preview-window p { max-width: 330px; color: #496b85; font: 400 14px var(--serif); }
.preview-window span { display: inline-flex; margin-top: 16px; padding: 9px 15px; border-radius: 999px; color: white; background: var(--ink); font-size: 11px; }
.preview-teacher { display: grid; grid-template-columns: 128px 1fr; gap: 22px; margin-top: 16px; padding: 22px; border-radius: 15px; background: linear-gradient(135deg, #eef4f8, #fff8f2); }
.preview-teacher img { width: 128px; height: 168px; border-radius: 12px; object-fit: cover; object-position: top; }
.preview-teacher h3 { margin: 7px 0; font: 500 25px var(--serif); }
.preview-teacher p { margin: 0 0 12px; color: #496b85; font-size: 12px; }
.preview-teacher small { display: block; color: var(--muted); line-height: 1.7; }
.content-admin-body, .settings-admin-body { padding: 22px; }
.content-tabs { display: flex; gap: 8px; margin-bottom: 16px; padding: 6px; width: fit-content; border: 1px solid var(--line); border-radius: 12px; background: white; }
.content-tabs button { padding: 10px 18px; border: 0; border-radius: 8px; color: var(--muted); background: transparent; cursor: pointer; }
.content-tabs button.active { color: white; background: var(--ink); }
.content-layout { display: grid; grid-template-columns: 280px minmax(0, 1fr); gap: 18px; }
.content-list, .content-editor { border: 1px solid rgba(29,64,91,.08); border-radius: 18px; background: white; box-shadow: 0 12px 32px rgba(44,72,96,.05); }
.content-list { display: flex; flex-direction: column; gap: 7px; align-self: start; padding: 12px; }
.content-list > button { display: grid; gap: 5px; padding: 13px; border: 0; border-radius: 10px; text-align: left; background: transparent; cursor: pointer; }
.content-list > button.active { color: #1f5a8b; background: #e8f0f8; }
.content-list > button strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.content-list > button small { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
.content-list .add-content { display: flex; align-items: center; justify-content: center; gap: 8px; margin-bottom: 5px; border: 1px dashed #aac0d2; color: #35698f; }
.content-editor { padding: 28px; }
.danger-link, .inline-add { display: inline-flex; align-items: center; gap: 6px; padding: 8px 11px; border: 0; border-radius: 8px; color: #9b4b43; background: #fff1ef; cursor: pointer; }
.inline-add { color: #35698f; background: #e9f1f8; }
.form-grid select { width: 100%; padding: 12px 13px; border: 1px solid #d5dfe7; border-radius: 9px; color: #1c3c56; background: #fbfcfd; }
.compact-upload { position: relative; justify-self: start; margin-top: 2px; padding: 7px 11px; overflow: hidden; border: 1px solid #ccd8e2; border-radius: 8px; cursor: pointer; }
.compact-upload input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.media-picker-trigger { display: inline-flex; align-items: center; justify-self: start; gap: 7px; margin-top: 3px; padding: 8px 11px; border: 1px solid #ccd8e2; border-radius: 8px; color: #35698f; background: white; cursor: pointer; }
.media-picker-backdrop { position: fixed; z-index: 1000; inset: 0; display: grid; padding: 28px; place-items: center; background: rgba(15,35,52,.34); backdrop-filter: blur(8px); }
.media-picker-dialog { display: grid; grid-template-rows: auto auto minmax(0,1fr); width: min(900px, 96vw); max-height: min(760px, 90vh); padding: 24px; border-radius: 24px; background: white; box-shadow: 0 34px 100px rgba(17,44,66,.28); }
.media-picker-dialog > header { display: flex; align-items: center; justify-content: space-between; }
.media-picker-dialog header small { color: #7890a3; letter-spacing: .18em; }
.media-picker-dialog h2 { margin: 5px 0 16px; font: 500 30px var(--serif); }
.media-picker-dialog header button { display: grid; width: 40px; height: 40px; place-items: center; border: 0; border-radius: 50%; background: #eef3f7; cursor: pointer; }
.media-picker-search { display: flex; align-items: center; gap: 10px; padding: 0 14px; border: 1px solid #d5dfe7; border-radius: 11px; }
.media-picker-search input { width: 100%; height: 44px; border: 0; outline: 0; }
.media-picker-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px,1fr)); gap: 12px; margin-top: 18px; padding-right: 4px; overflow: auto; }
.media-picker-grid button { position: relative; display: grid; gap: 7px; min-width: 0; padding: 8px; border: 1px solid transparent; border-radius: 14px; text-align: left; background: #f5f8fa; cursor: pointer; }
.media-picker-grid button:hover, .media-picker-grid button.selected { border-color: #7396b3; background: #edf4f9; }
.media-picker-grid img { width: 100%; aspect-ratio: 4/3; border-radius: 10px; object-fit: cover; }
.media-picker-grid span, .media-picker-grid small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.media-picker-grid small { color: #7890a3; }
.media-picker-grid svg { position: absolute; top: 14px; right: 14px; padding: 4px; border-radius: 50%; color: white; background: #2e648e; box-sizing: content-box; }
.media-picker-empty { display: grid; min-height: 260px; place-items: center; color: var(--muted); }
.empty-editor { padding: 100px 20px; color: var(--muted); text-align: center; }
.settings-admin-body { display: grid; gap: 18px; max-width: 1080px; }
.subsection-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 30px; }
.subsection-heading h3 { margin: 0; font: 500 17px var(--serif); }
.repeat-settings article { position: relative; margin-top: 18px; padding: 0 18px 18px; border: 1px solid var(--line); border-radius: 14px; background: #fafcfd; }
.repeat-settings article > .danger-link { margin-top: 14px; }
.health-ok { color: #317346 !important; }
.audit-list { display: grid; padding-top: 10px; }
.audit-list article { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 13px 3px; border-bottom: 1px solid var(--line); }
.audit-list article > div { display: flex; align-items: center; gap: 12px; }
.audit-list article span, .audit-list article small { color: var(--muted); font-size: 12px; }
.centered-links { justify-content: center; margin-top: 42px; }
.authority-links a.disabled { pointer-events: none; opacity: .62; }
.compact-team { max-width: 1180px; margin: 0 auto; }
.center-action { display: flex; justify-content: center; margin-top: 34px; }
.media-admin-body { display: grid; gap: 18px; padding: 22px; }
.media-upload-panel { display: grid; grid-template-columns: 240px minmax(0, 1fr) 180px; align-items: center; gap: 18px; padding: 22px; }
.media-upload-panel > div:first-child { display: grid; grid-template-columns: 38px 1fr; align-items: center; color: #35698f; }
.media-upload-panel > div:first-child small { grid-column: 2; margin-top: 3px; color: var(--muted); }
.media-upload-meta { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
.media-upload-meta input, .media-upload-meta select { min-width: 0; padding: 10px 11px; border: 1px solid #d5dfe7; border-radius: 8px; background: #fbfcfd; }
.media-upload-button { position: relative; display: grid; min-height: 44px; place-items: center; overflow: hidden; border-radius: 9px; color: white; background: var(--ink); cursor: pointer; }
.media-upload-button input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.media-library-layout { display: grid; grid-template-columns: minmax(0, 1fr) 380px; gap: 18px; align-items: start; }
.media-grid-panel { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; min-height: 360px; padding: 14px; border: 1px solid rgba(29,64,91,.08); border-radius: 18px; background: white; }
.media-tile { position: relative; display: grid; align-content: start; gap: 6px; min-width: 0; padding: 8px; border: 1px solid transparent; border-radius: 13px; text-align: left; background: #f7f9fb; cursor: pointer; }
.media-tile.active { border-color: #6d96b7; background: #eef4f9; box-shadow: 0 0 0 3px rgba(78,128,168,.08); }
.media-tile img { width: 100%; aspect-ratio: 4/3; border-radius: 9px; object-fit: cover; background: #e9eef2; }
.media-tile span, .media-tile small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.media-tile small { color: var(--muted); }
.media-tile b { position: absolute; top: 14px; right: 14px; padding: 4px 7px; border-radius: 999px; color: #2f7448; background: #e5f4e9; font-size: 10px; }
.media-detail { position: sticky; top: 108px; padding: 18px; }
.media-detail > img { width: 100%; max-height: 300px; border-radius: 12px; object-fit: contain; background: #edf1f4; }
.media-detail-meta { display: grid; gap: 5px; padding: 14px 2px 0; }
.media-detail-meta small { color: var(--muted); }
.media-detail-meta a { justify-self: start; color: #35698f; font-size: 12px; }
.media-detail-actions { display: flex; gap: 8px; margin-top: 18px; }
.media-detail-actions button { display: inline-flex; align-items: center; gap: 7px; padding: 10px 13px; border: 1px solid #ccd8e2; border-radius: 9px; background: white; cursor: pointer; }
.revision-list { display: grid; gap: 8px; padding-top: 20px; }
.revision-list article { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 13px 14px; border: 1px solid var(--line); border-radius: 10px; background: #fafcfd; }
.revision-list article > div { display: flex; align-items: center; gap: 16px; }
.revision-list small { color: var(--muted); }
.revision-list button { padding: 8px 11px; border: 1px solid #ccd8e2; border-radius: 8px; color: #35698f; background: white; cursor: pointer; }
.password-action { display: flex; align-items: end; }
@keyframes hero-in { from { opacity: 0; transform: translateY(26px); filter: blur(6px); } to { opacity: 1; transform: translateY(calc(var(--hero-scroll) * -38px)); filter: none; } }
@keyframes title-rise { from { opacity: 0; transform: translateY(34px); } to { opacity: 1; transform: none; } }
@keyframes section-rise { from { opacity: 0; transform: translateY(28px); } to { opacity: 1; transform: none; } }
@keyframes metric-in { from { opacity: 0; transform: translateX(26px); } to { opacity: 1; transform: none; } }
@keyframes panel-wipe { from { opacity: .4; clip-path: inset(0 100% 0 0 round 38px); transform: translateY(22px); } to { opacity: 1; clip-path: inset(0 0 0 0 round 38px); transform: none; } }
@keyframes float-cue { 0%,100% { transform: translate(-50%, 0); } 50% { transform: translate(-50%, 7px); } }
@media (max-width: 1100px) {
.site-header { position: fixed; grid-template-columns: 1fr auto; min-height: 72px; padding: 12px 24px; border-bottom: 1px solid rgba(28,62,88,.08); background: rgba(248,250,252,.9); backdrop-filter: blur(18px); }
.menu-toggle { z-index: 3; display: inline-flex; padding: 10px; cursor: pointer; }
.main-nav { position: fixed; top: 0; left: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100vw; height: 100svh; gap: 28px; visibility: hidden; opacity: 0; background: rgba(247,249,251,.97); transition: visibility .28s ease, opacity .28s ease; }
.main-nav.open { visibility: visible; opacity: 1; }
.main-nav a { display: flex; min-width: 180px; min-height: 48px; justify-content: center; font: 500 24px var(--serif); }
.header-cta { display: none; }
.teacher-card { grid-template-columns: .9fr 1.1fr; }
.official-grid { grid-template-columns: repeat(2, 1fr); }
.team-grid { grid-template-columns: 1fr; max-width: 760px; }
.admin-workspace { grid-template-columns: 1fr; }
.preview-panel { position: static; }
}
@media (max-width: 760px) {
:root { --section-space: 68px; }
.section-pad { padding: var(--section-space) 20px; }
.site-header { min-height: 70px; padding: 11px 18px; }
.section-heading.centered { margin-bottom: 40px; }
.section-heading > p:last-child { font-size: 16px; line-height: 1.82; }
.eyebrow { font-size: 12px; letter-spacing: .22em; }
.hero-copy { width: 100%; padding: 25vh 22px 130px; text-align: center; }
.hero-copy h1 { font-size: clamp(50px, 14.2vw, 56px); letter-spacing: .045em; }
.hero-subtitle { margin-bottom: 30px; font-size: 17px; line-height: 1.65; letter-spacing: .06em; }
.hero { min-height: 100svh; background-position: 68% center; }
.hero-wave { bottom: -105px; }
.about-layout, .teacher-card, .news-panel, .login-page { grid-template-columns: 1fr; }
.listing-grid, .team-grid { grid-template-columns: 1fr; }
.public-page-main { padding-top: 70px; }
.listing-hero { padding-top: 80px; }
.article-list > a { grid-template-columns: 1fr 20px; }
.article-list img { grid-column: 1/3; width: 100%; height: 210px; }
.detail-hero, .detail-content { grid-template-columns: 1fr; }
.detail-hero { padding-top: 70px; text-align: center; }
.detail-hero > div { padding-left: 0; }
.detail-hero img { height: 460px; }
.detail-content { gap: 28px; padding-top: 45px; }
.team-grid article { grid-template-columns: 120px 1fr; }
.team-grid img { width: 120px; height: 175px; }
.authority-links { flex-wrap: wrap; }
.authority-links h2 { width: 100%; margin: 0; text-align: center; }
.about { min-height: 0; padding-top: 48px; }
.about-layout { gap: 28px; }
.about-visual { min-height: 270px; border-radius: 28px; }
.metric-grid { gap: 12px; }
.metric-card { grid-template-columns: 38px 90px minmax(0, 1fr); column-gap: 8px; min-height: 104px; padding: 17px 18px; border-radius: 21px; }
.metric-card strong { font-size: 25px; }
.metric-card span { font-size: 14px; }
.metric-card small { font-size: 13px; }
.teacher-card { min-height: 0; }
.teacher-portrait { height: 470px; }
.teacher-copy { padding: 38px 28px 46px; }
.teacher-copy h2 { font-size: 42px; }
.teacher-title { font-size: 17px; }
.philosophy-grid, .product-grid { grid-template-columns: 1fr; }
.philosophy-grid, .product-grid { gap: 18px; }
.philosophy-card { min-height: 250px; padding: 34px; }
.philosophy-card h3 { margin-top: 70px; font-size: 36px; }
.product-card img { height: 190px; }
.product-card div { padding: 21px 8px 23px 23px; }
.product-card h3 { font-size: 22px; }
.split-heading { display: block; }
.split-heading > p { margin-top: 22px; }
.official-grid { grid-template-columns: 1fr 1fr; }
.official-grid { gap: 12px; }
.official-grid article { min-height: 165px; padding: 22px 18px; border-radius: 22px; }
.official-grid h3 { margin: 24px 0 7px; font-size: 20px; }
.authority-links { gap: 12px; padding: 48px 0 0; }
.authority-links h2 { font-size: 26px; }
.news-feature { min-height: 300px; padding: 42px 28px; }
.news-list { padding: 20px 28px 36px; }
.news-list a { grid-template-columns: 1fr 18px; }
.news-list time { display: none; }
.site-footer { grid-template-columns: 1fr; gap: 32px; padding: 52px 20px 22px; }
.footer-contact { justify-content: start; }
.footer-meta { grid-column: 1; flex-direction: column; gap: 8px; }
.draft-preview-ribbon { right: 12px; bottom: 12px; left: 12px; justify-content: space-between; }
.draft-preview-ribbon span { display: none; }
.media-picker-backdrop { padding: 10px; }
.media-picker-dialog { max-height: 94vh; padding: 18px; border-radius: 18px; }
.media-picker-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.login-page { padding: 18px; background-position: 66% center; }
.login-brand { min-height: 110px; padding: 4px; }
.login-brand > div { display: none; }
.login-card { padding: 38px 24px; border-radius: 26px; }
.admin-shell { grid-template-columns: 1fr; }
.admin-sidebar { display: none; }
.admin-topbar { align-items: flex-start; gap: 12px; padding: 14px 16px; }
.admin-topbar > div:first-child small { display: none; }
.admin-actions a, .save-message { display: none; }
.admin-actions button { padding: 0 10px; font-size: 12px; }
.admin-workspace { padding: 12px; }
.content-admin-body, .settings-admin-body { padding: 12px; }
.media-admin-body { padding: 12px; }
.media-upload-panel, .media-library-layout { grid-template-columns: 1fr; }
.media-detail { position: static; }
.content-layout { grid-template-columns: 1fr; }
.content-list { max-height: 300px; overflow: auto; }
.content-editor { padding: 20px 16px; }
.editor-panel { padding: 20px 16px; }
.form-grid { grid-template-columns: 1fr; }
.form-grid label.full { grid-column: 1; }
}
@media (max-width: 520px) {
.team-grid { padding-right: 20px; padding-left: 20px; }
.team-grid article { grid-template-columns: 1fr; gap: 16px; padding: 18px; }
.team-grid img { width: 100%; height: auto; aspect-ratio: 4 / 3; }
.team-grid h2, .team-grid h3 { margin-top: 4px; }
}
@media (hover: none) {
.metric-card:hover, .philosophy-card:hover, .product-card:hover, .official-grid article:hover, .listing-card:hover { transform: none; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
.reveal-section { opacity: 1; transform: none; }
.reveal-section > *, .metric-card, .teacher-card { opacity: 1; clip-path: none; transform: none; }
.hero-media img, .hero-copy, .teacher-portrait img, .about-visual img { transform: none !important; }
}

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { PhArticle, PhHouse, PhImageSquare, PhSignOut, PhSlidersHorizontal } from '@phosphor-icons/vue'
defineProps<{ active: 'home' | 'content' | 'media' | 'settings'; logo?: string; brandName?: string; englishName?: string }>()
async function logout() {
await $fetch('/api/auth/logout', { method: 'POST' })
await navigateTo('/admin/login')
}
</script>
<template>
<aside class="admin-sidebar">
<BrandLogo :src="logo" :name="brandName" :english-name="englishName" />
<nav>
<NuxtLink to="/admin" :class="{ active: active === 'home' }"><PhHouse :size="21" />首页管理</NuxtLink>
<NuxtLink to="/admin/content" :class="{ active: active === 'content' }"><PhArticle :size="21" />内容管理</NuxtLink>
<NuxtLink to="/admin/media" :class="{ active: active === 'media' }"><PhImageSquare :size="21" />素材中心</NuxtLink>
<NuxtLink to="/admin/settings" :class="{ active: active === 'settings' }"><PhSlidersHorizontal :size="21" />网站设置</NuxtLink>
</nav>
<button type="button" @click="logout"><PhSignOut :size="20" />退出登录</button>
</aside>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
const props = defineProps<{ value: string }>()
const root = ref<HTMLElement>()
const shown = ref(props.value)
let observer: IntersectionObserver | undefined
onMounted(() => {
const match = props.value.match(/^(\d+)(.*)$/)
if (!match || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
const target = Number(match[1])
const suffix = match[2]
shown.value = `0${suffix}`
observer = new IntersectionObserver(([entry]) => {
if (!entry?.isIntersecting) return
observer?.disconnect()
const startedAt = performance.now()
const duration = 620
const tick = (now: number) => {
const progress = Math.min(1, (now - startedAt) / duration)
const eased = 1 - Math.pow(1 - progress, 3)
shown.value = `${Math.round(target * eased)}${suffix}`
if (progress < 1) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}, { threshold: 0.7 })
if (root.value) observer.observe(root.value)
})
onBeforeUnmount(() => observer?.disconnect())
</script>
<template><strong ref="root">{{ shown }}</strong></template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
withDefaults(defineProps<{
src?: string
name?: string
englishName?: string
}>(), {
src: '/images/brand-mark-v2.png',
name: '慧遇书院',
englishName: 'HUIYU ACADEMY',
})
</script>
<template>
<NuxtLink to="/" class="brand-logo" :aria-label="`返回${name}首页`">
<img :src="src" :alt="name">
<span class="brand-logo-copy"><strong>{{ name }}</strong><small v-if="englishName">{{ englishName }}</small></span>
</NuxtLink>
</template>
<style scoped>
.brand-logo {
display: inline-flex;
align-items: center;
}
.brand-logo img {
display: block;
width: 48px;
height: 48px;
object-fit: contain;
}
.brand-logo-copy { display: grid; margin-left: 10px; line-height: 1; white-space: nowrap; }
.brand-logo-copy strong { font: 600 23px/1.15 var(--serif); letter-spacing: .08em; }
.brand-logo-copy small { margin-top: 3px; font-size: 8px; letter-spacing: .12em; }
@media (max-width: 720px) {
.brand-logo img { width: 38px; height: 38px; }
.brand-logo-copy { margin-left: 8px; }
.brand-logo-copy strong { font-size: 18px; }
.brand-logo-copy small { font-size: 7px; }
}
</style>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { PhShieldCheck as ShieldCheck } from '@phosphor-icons/vue'
import { getPoliceFilingUrl, MIIT_FILING_URL } from '~/utils/filing'
const props = defineProps<{ icp: string; policeRecord?: string }>()
const policeRecord = computed(() => props.policeRecord?.trim() || '')
const policeFilingUrl = computed(() => getPoliceFilingUrl(policeRecord.value))
</script>
<template>
<span class="filing-links">
<a :href="MIIT_FILING_URL" target="_blank" rel="noopener noreferrer">{{ icp }}</a>
<template v-if="policeRecord">
<span aria-hidden="true">·</span>
<a class="police-filing-link" :href="policeFilingUrl" target="_blank" rel="noopener noreferrer">
<ShieldCheck :size="16" weight="fill" aria-hidden="true" />
{{ policeRecord }}
</a>
</template>
</span>
</template>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { PhCheck, PhImageSquare, PhMagnifyingGlass, PhX } from '@phosphor-icons/vue'
interface MediaAsset {
id: number
originalName: string
url: string
thumbnailUrl: string
altText: string
authorizationStatus: 'pending' | 'authorized' | 'restricted'
}
const model = defineModel<string>({ required: true })
const props = withDefaults(defineProps<{ label?: string }>(), { label: '从素材库选择' })
const open = ref(false)
const loading = ref(false)
const query = ref('')
const items = ref<MediaAsset[]>([])
const filtered = computed(() => {
const keyword = query.value.trim().toLowerCase()
return keyword ? items.value.filter(item => `${item.originalName} ${item.altText}`.toLowerCase().includes(keyword)) : items.value
})
async function show() {
open.value = true
if (items.value.length) return
loading.value = true
try {
items.value = (await $fetch<{ data: MediaAsset[] }>('/api/admin/media', { query: { status: 'active' } })).data
} finally {
loading.value = false
}
}
function select(item: MediaAsset) {
model.value = item.url
open.value = false
}
</script>
<template>
<button type="button" class="media-picker-trigger" @click="show"><PhImageSquare :size="17" />{{ props.label }}</button>
<Teleport to="body">
<div v-if="open" class="media-picker-backdrop" role="presentation" @click.self="open = false">
<section class="media-picker-dialog" role="dialog" aria-modal="true" aria-labelledby="media-picker-title" @keydown.esc="open = false">
<header><div><small>MEDIA LIBRARY</small><h2 id="media-picker-title">选择素材</h2></div><button type="button" aria-label="关闭素材选择" @click="open = false"><PhX :size="22" /></button></header>
<label class="media-picker-search"><PhMagnifyingGlass :size="18" /><input v-model="query" autofocus placeholder="搜索文件名或 ALT 描述"></label>
<div v-if="loading" class="media-picker-empty">正在读取素材</div>
<div v-else-if="!filtered.length" class="media-picker-empty">素材库暂无可选图片</div>
<div v-else class="media-picker-grid">
<button v-for="item in filtered" :key="item.id" type="button" :class="{ selected: model === item.url }" @click="select(item)">
<img :src="item.thumbnailUrl" :alt="item.altText || item.originalName" loading="lazy">
<span>{{ item.altText || item.originalName }}</span>
<small>{{ item.authorizationStatus === 'authorized' ? '已授权' : item.authorizationStatus === 'restricted' ? '限制使用' : '待核验' }}</small>
<PhCheck v-if="model === item.url" :size="18" weight="bold" />
</button>
</div>
</section>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { SiteDocument } from '../../server/utils/site-schema'
defineProps<{ site: SiteDocument }>()
</script>
<template>
<div class="public-page-shell">
<SiteHeader :logo="site.brand.logo" :brand-name="site.brand.name" :english-name="site.brand.englishName" />
<main id="main-content" class="public-page-main"><slot /></main>
<footer id="contact" class="site-footer">
<div class="footer-brand"><BrandLogo :src="site.brand.logo" :name="site.brand.name" :english-name="site.brand.englishName" /><p>{{ site.brand.subSlogan }}</p></div>
<div class="footer-contact">
<p><a v-if="!/[待演示]/.test(site.contact.phone)" :href="`tel:${site.contact.phone.replace(/\s/g, '')}`">{{ site.contact.phone }}</a><span v-else>{{ site.contact.phone }}</span></p>
<p><a v-if="site.contact.email.includes('@') && !site.contact.email.includes('演示')" :href="`mailto:${site.contact.email}`">{{ site.contact.email }}</a><span v-else>{{ site.contact.email }}</span></p>
<p>{{ site.contact.address }}</p>
</div>
<div class="footer-meta">
<span>{{ site.brand.copyright }}</span>
<span><NuxtLink to="/privacy">隐私政策</NuxtLink> · <NuxtLink to="/terms">服务条款</NuxtLink> · <FilingLinks :icp="site.brand.icp" :police-record="site.brand.policeRecord" /></span>
</div>
</footer>
</div>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
const root = ref<HTMLElement>()
const visible = ref(false)
let observer: IntersectionObserver | undefined
onMounted(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
visible.value = true
return
}
observer = new IntersectionObserver(([entry]) => {
if (entry?.isIntersecting) {
visible.value = true
observer?.disconnect()
}
}, { threshold: 0.16 })
if (root.value) observer.observe(root.value)
})
onBeforeUnmount(() => observer?.disconnect())
</script>
<template>
<section ref="root" :class="['reveal-section', { visible }]">
<slot />
</section>
</template>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import { PhArrowRight as ArrowRight, PhList as List, PhX as X } from '@phosphor-icons/vue'
const open = ref(false)
const toggle = ref<HTMLButtonElement>()
const navigation = ref<HTMLElement>()
const props = withDefaults(defineProps<{ logo?: string; brandName?: string; englishName?: string }>(), {
brandName: '慧遇书院',
})
const route = useRoute()
const links = computed(() => [
{ label: '首页', href: '/#home' },
{ label: `关于${props.brandName}`, href: '/#about' },
{ label: '卢慧老师', href: '/luhui' },
{ label: '课程与产品', href: '/products' },
{ label: '官方信息', href: '/#official' },
{ label: '品牌动态', href: '/news' },
{ label: '联系我们', href: '/#contact' },
])
function closeMenu(returnFocus = false) {
open.value = false
if (returnFocus) nextTick(() => toggle.value?.focus())
}
function isActive(href: string) {
if (href.startsWith('/#')) return route.path === '/' && (href === '/#home' || route.hash === href.slice(1))
return route.path === href || route.path.startsWith(`${href}/`)
}
watch(open, (value) => {
if (!import.meta.client) return
document.body.classList.toggle('nav-open', value)
if (value) nextTick(() => navigation.value?.querySelector<HTMLAnchorElement>('a')?.focus())
})
onBeforeUnmount(() => {
if (import.meta.client) document.body.classList.remove('nav-open')
})
</script>
<template>
<header class="site-header">
<BrandLogo :src="logo" :name="brandName" :english-name="englishName" />
<button ref="toggle" class="menu-toggle" type="button" :aria-expanded="open" aria-controls="site-navigation" :aria-label="open ? '关闭导航' : '打开导航'" @click="open = !open" @keydown.esc="closeMenu(true)">
<X v-if="open" :size="24" />
<List v-else :size="24" />
</button>
<nav id="site-navigation" ref="navigation" :class="['main-nav', { open }]" aria-label="主导航" @keydown.esc="closeMenu(true)">
<a v-for="link in links" :key="link.href" :href="link.href" :aria-current="isActive(link.href) ? 'page' : undefined" @click="closeMenu()">{{ link.label }}</a>
</nav>
<a class="header-cta" href="/#official">
官方渠道
<ArrowRight :size="18" weight="light" />
</a>
</header>
</template>

View File

@@ -0,0 +1,32 @@
import type { SiteDocument } from '../../server/utils/site-schema'
export async function useSiteDocument() {
const route = useRoute()
const isPreview = computed(() => route.query.preview === 'draft')
const endpoint = computed(() => isPreview.value ? '/api/admin/site' : '/api/public/site')
const headers = import.meta.server && isPreview.value ? useRequestHeaders(['cookie']) : undefined
useHead(() => isPreview.value ? { meta: [{ name: 'robots', content: 'noindex, nofollow' }] } : {})
const { data: response, error } = await useFetch<{ data: SiteDocument }>(endpoint, { headers })
if (error.value) {
if (isPreview.value && error.value.statusCode === 401) {
throw createError({ statusCode: 401, statusMessage: '预览草稿前请先登录后台' })
}
throw error.value
}
return {
site: computed(() => response.value!.data),
isPreview,
}
}
export function useAbsoluteUrl() {
const config = useRuntimeConfig()
const base = String(config.public.siteUrl).replace(/\/$/, '')
return (path?: string | null) => {
if (!path) return base
if (/^https?:\/\//i.test(path)) return path
return `${base}${path.startsWith('/') ? path : `/${path}`}`
}
}

View File

@@ -0,0 +1,35 @@
import type { Ref } from 'vue'
export function useUnsavedChanges<T>(source: Ref<T | undefined>) {
const dirty = ref(false)
let baseline = ''
function snapshot() {
baseline = JSON.stringify(source.value ?? null)
dirty.value = false
}
watch(source, (value) => {
if (!baseline) return
dirty.value = JSON.stringify(value ?? null) !== baseline
}, { deep: true })
function beforeUnload(event: BeforeUnloadEvent) {
if (!dirty.value) return
event.preventDefault()
event.returnValue = ''
}
onMounted(() => window.addEventListener('beforeunload', beforeUnload))
onBeforeUnmount(() => window.removeEventListener('beforeunload', beforeUnload))
onBeforeRouteLeave(() => !dirty.value || window.confirm('当前修改尚未保存,确定离开吗?'))
return { dirty, markClean: snapshot }
}
export function apiErrorMessage(error: any, fallback: string) {
const issue = error?.data?.data?.issues?.[0]
if (!issue) return error?.data?.statusMessage || fallback
const path = Array.isArray(issue.path) && issue.path.length ? `${issue.path.join('.')}` : ''
return `${path}${issue.message || fallback}`
}

149
app/pages/admin/content.vue Normal file
View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import { PhCheckCircle, PhFloppyDisk, PhPlus, PhTrash, PhUploadSimple } from '@phosphor-icons/vue'
import { apiErrorMessage } from '~/composables/useUnsavedChanges'
import type { SiteDocument } from '../../../server/utils/site-schema'
definePageMeta({ layout: false })
useHead(() => ({ title: `内容管理|${document.value?.brand.name || '慧遇书院'}` }))
type ContentTab = 'products' | 'articles' | 'team'
const tab = ref<ContentTab>('products')
const selected = reactive({ products: 0, articles: 0, team: 0 })
const document = ref<SiteDocument>()
const loading = ref(true)
const busy = ref(false)
const message = ref('')
const { dirty, markClean } = useUnsavedChanges(document)
onMounted(async () => {
try {
await $fetch('/api/auth/me')
document.value = (await $fetch<{ data: SiteDocument }>('/api/admin/site')).data
await nextTick()
markClean()
} catch { await navigateTo('/admin/login') }
finally { loading.value = false }
})
const currentProduct = computed(() => document.value?.products[selected.products])
const currentArticle = computed(() => document.value?.articles[selected.articles])
const currentMember = computed(() => document.value?.team[selected.team])
function makeSlug(prefix: string) { return `${prefix}-${Date.now().toString(36)}` }
function addItem() {
if (!document.value) return
if (tab.value === 'products') {
document.value.products.push({ slug: makeSlug('product'), title: '新产品', subtitle: '', type: 'course', summary: '', content: '', image: '/images/hero-bg-v3.webp', href: '', status: 'draft' })
selected.products = document.value.products.length - 1
} else if (tab.value === 'articles') {
document.value.articles.push({ slug: makeSlug('article'), type: 'news', title: '新内容', summary: '', content: '', cover: '/images/hero-bg-v3.webp', publishedAt: new Date().toISOString().slice(0, 10), status: 'draft', statementNo: '', sourceName: '', sourceUrl: '' })
selected.articles = document.value.articles.length - 1
} else {
document.value.team.push({ slug: makeSlug('member'), name: '新成员', role: '', summary: '', avatar: '/images/lu-hui-portrait.jpg', specialties: [], status: 'draft' })
selected.team = document.value.team.length - 1
}
}
function removeItem() {
if (!document.value || !window.confirm('确定删除当前内容吗?保存后生效。')) return
document.value[tab.value].splice(selected[tab.value], 1 as never)
selected[tab.value] = Math.max(0, selected[tab.value] - 1)
}
async function save(publish = false) {
if (!document.value) return
busy.value = true
message.value = ''
try {
await $fetch('/api/admin/site', { method: 'PUT', body: document.value })
if (publish) {
const result = await $fetch<{ version: number }>('/api/admin/site/publish', { method: 'POST' })
message.value = `已发布 V${result.version}`
} else message.value = '草稿已保存'
markClean()
} catch (error: any) { message.value = apiErrorMessage(error, '保存失败,请检查必填项和英文标识') }
finally { busy.value = false }
}
const previewHref = computed(() => {
if (tab.value === 'products' && currentProduct.value) return `/products/${currentProduct.value.slug}?preview=draft`
if (tab.value === 'articles' && currentArticle.value) return `${currentArticle.value.type === 'statement' ? '/statements' : '/news'}/${currentArticle.value.slug}?preview=draft`
return '/team?preview=draft'
})
async function uploadImage(event: Event, apply: (url: string) => void) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
busy.value = true
try {
const body = new FormData(); body.append('file', file)
const result = await $fetch<{ url: string }>('/api/admin/media', { method: 'POST', body })
apply(result.url); message.value = '图片已上传,请保存并发布'
} catch (error: any) { message.value = error?.data?.statusMessage || '图片上传失败' }
finally { busy.value = false; input.value = '' }
}
</script>
<template>
<main class="admin-shell">
<AdminSidebar active="content" :logo="document?.brand.logo" :brand-name="document?.brand.name" :english-name="document?.brand.englishName" />
<section class="admin-main">
<header class="admin-topbar">
<div><p>内容管理</p><small>课程产品品牌动态官方声明与陪伴团队</small></div>
<div class="admin-actions">
<span v-if="dirty" class="dirty-indicator">有未保存修改</span>
<span v-if="message" class="save-message"><PhCheckCircle :size="18" />{{ message }}</span>
<a :href="previewHref" target="_blank">预览草稿</a>
<button :disabled="busy" @click="save(false)"><PhFloppyDisk :size="18" />保存草稿</button>
<button class="publish-button" :disabled="busy" @click="save(true)"><PhUploadSimple :size="18" />发布更新</button>
</div>
</header>
<div v-if="loading" class="admin-loading">正在加载内容</div>
<div v-else-if="document" class="content-admin-body">
<div class="content-tabs">
<button v-for="item in ([['products','课程与产品'],['articles','动态与声明'],['team','陪伴团队']] as const)" :key="item[0]" :class="{ active: tab === item[0] }" @click="tab = item[0]">{{ item[1] }}</button>
</div>
<div class="content-layout">
<aside class="content-list">
<button class="add-content" @click="addItem"><PhPlus :size="18" />新建内容</button>
<template v-if="tab === 'products'">
<button v-for="(item, index) in document.products" :key="item.slug" :class="{ active: selected.products === index }" @click="selected.products = index"><strong>{{ item.title }}</strong><small>{{ item.status }} · {{ item.slug }}</small></button>
</template>
<template v-if="tab === 'articles'">
<button v-for="(item, index) in document.articles" :key="item.slug" :class="{ active: selected.articles === index }" @click="selected.articles = index"><strong>{{ item.title }}</strong><small>{{ item.type }} · {{ item.status }}</small></button>
</template>
<template v-if="tab === 'team'">
<button v-for="(item, index) in document.team" :key="item.slug" :class="{ active: selected.team === index }" @click="selected.team = index"><strong>{{ item.name }}</strong><small>{{ item.role || '未填写角色' }} · {{ item.status }}</small></button>
</template>
</aside>
<section class="content-editor">
<div class="panel-heading"><div><span>编辑</span><h1>{{ tab === 'products' ? '课程与产品' : tab === 'articles' ? '动态与声明' : '陪伴团队' }}</h1></div><button class="danger-link" :disabled="!(document[tab]?.length)" @click="removeItem"><PhTrash :size="17" />删除</button></div>
<div v-if="tab === 'products' && currentProduct" class="form-grid">
<label>标题<input v-model="currentProduct.title" maxlength="60"></label><label>英文标识<input v-model="currentProduct.slug" placeholder="family-course"></label>
<label>类型<select v-model="currentProduct.type"><option value="course">课程</option><option value="service">服务</option><option value="tool">工具</option></select></label><label>状态<select v-model="currentProduct.status"><option value="draft">草稿</option><option value="published">已发布</option><option value="offline">已下线</option></select></label>
<label class="full">副标题<input v-model="currentProduct.subtitle"></label><label class="full">摘要<textarea v-model="currentProduct.summary" rows="3" /></label><label class="full">正文<textarea v-model="currentProduct.content" rows="12" /></label>
<label class="full">封面地址<input v-model="currentProduct.image"><span class="compact-upload"><input type="file" accept="image/png,image/jpeg,image/webp" @change="uploadImage($event, url => currentProduct!.image = url)">上传本地图片</span><MediaPicker v-model="currentProduct.image" /></label>
</div>
<div v-else-if="tab === 'articles' && currentArticle" class="form-grid">
<label>标题<input v-model="currentArticle.title"></label><label>英文标识<input v-model="currentArticle.slug"></label>
<label>类型<select v-model="currentArticle.type"><option value="news">品牌动态</option><option value="statement">官方声明</option><option value="media">媒体报道</option></select></label><label>状态<select v-model="currentArticle.status"><option value="draft">草稿</option><option value="published">已发布</option><option value="offline">已下线</option></select></label>
<label>发布日期<input v-model="currentArticle.publishedAt" type="date"></label><label>声明编号<input v-model="currentArticle.statementNo"></label>
<label class="full">摘要<textarea v-model="currentArticle.summary" rows="3" /></label><label class="full">正文<textarea v-model="currentArticle.content" rows="14" /></label>
<label>来源名称<input v-model="currentArticle.sourceName"></label><label>来源链接<input v-model="currentArticle.sourceUrl"></label>
<label class="full">封面地址<input v-model="currentArticle.cover"><span class="compact-upload"><input type="file" accept="image/png,image/jpeg,image/webp" @change="uploadImage($event, url => currentArticle!.cover = url)">上传本地图片</span><MediaPicker v-model="currentArticle.cover" /></label>
</div>
<div v-else-if="tab === 'team' && currentMember" class="form-grid">
<label>姓名<input v-model="currentMember.name"></label><label>英文标识<input v-model="currentMember.slug"></label>
<label>角色<input v-model="currentMember.role"></label><label>状态<select v-model="currentMember.status"><option value="draft">草稿</option><option value="published">已发布</option><option value="offline">已下线</option></select></label>
<label class="full">简介<textarea v-model="currentMember.summary" rows="5" /></label><label class="full">专长(用中文逗号分隔)<input :value="currentMember.specialties.join('')" @input="currentMember.specialties = ($event.target as HTMLInputElement).value.split(/[,]/).map(v => v.trim()).filter(Boolean).slice(0, 8)"></label>
<label class="full">头像地址<input v-model="currentMember.avatar"><span class="compact-upload"><input type="file" accept="image/png,image/jpeg,image/webp" @change="uploadImage($event, url => currentMember!.avatar = url)">上传本地图片</span><MediaPicker v-model="currentMember.avatar" /></label>
</div>
<div v-else class="empty-editor">当前分类暂无内容点击新建内容开始编辑</div>
</section>
</div>
</div>
</section>
</main>
</template>

176
app/pages/admin/index.vue Normal file
View File

@@ -0,0 +1,176 @@
<script setup lang="ts">
import {
PhArrowSquareOut as ArrowSquareOut,
PhCheckCircle as CheckCircle,
PhFloppyDisk as FloppyDisk,
PhImageSquare as ImageSquare,
PhUploadSimple as UploadSimple,
} from '@phosphor-icons/vue'
import { apiErrorMessage } from '~/composables/useUnsavedChanges'
import type { SiteDocument } from '../../../server/utils/site-schema'
definePageMeta({ layout: false })
useHead(() => ({ title: `首页管理|${document.value?.brand.name || '慧遇书院'}` }))
const loading = ref(true)
const saving = ref(false)
const publishing = ref(false)
const message = ref('')
const document = ref<SiteDocument>()
const user = ref<{ displayName: string }>()
const uploadBusy = ref<string>()
const { dirty, markClean } = useUnsavedChanges(document)
onMounted(async () => {
try {
const [me, site] = await Promise.all([
$fetch<{ user: { displayName: string } }>('/api/auth/me'),
$fetch<{ data: SiteDocument }>('/api/admin/site'),
])
user.value = me.user
document.value = site.data
await nextTick()
markClean()
} catch {
await navigateTo('/admin/login')
} finally {
loading.value = false
}
})
async function save() {
if (!document.value) return
saving.value = true
message.value = ''
try {
await $fetch('/api/admin/site', { method: 'PUT', body: document.value })
message.value = '草稿已保存'
markClean()
} catch (error: any) {
message.value = apiErrorMessage(error, '保存失败,请检查页面内容')
} finally {
saving.value = false
}
}
async function publish() {
await save()
publishing.value = true
try {
const result = await $fetch<{ version: number }>('/api/admin/site/publish', { method: 'POST' })
message.value = `已发布 V${result.version}`
markClean()
} catch (error: any) {
message.value = apiErrorMessage(error, '发布失败,请检查页面内容')
} finally {
publishing.value = false
}
}
async function uploadImage(event: Event, target: 'brand.logo' | 'hero.backgroundImage' | 'teacher.portrait') {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file || !document.value) return
uploadBusy.value = target
message.value = ''
try {
const body = new FormData()
body.append('file', file)
const result = await $fetch<{ url: string }>('/api/admin/media', { method: 'POST', body })
if (target === 'brand.logo') document.value.brand.logo = result.url
if (target === 'hero.backgroundImage') document.value.hero.backgroundImage = result.url
if (target === 'teacher.portrait') document.value.teacher.portrait = result.url
message.value = '图片已上传,请保存草稿并发布'
} catch (error: any) {
message.value = error?.data?.statusMessage || '图片上传失败'
} finally {
uploadBusy.value = undefined
input.value = ''
}
}
</script>
<template>
<main class="admin-shell">
<AdminSidebar active="home" :logo="document?.brand.logo" :brand-name="document?.brand.name" :english-name="document?.brand.englishName" />
<section class="admin-main">
<header class="admin-topbar">
<div><p>首页管理</p><small>维护官网核心文案并实时预览</small></div>
<div class="admin-actions">
<span v-if="dirty" class="dirty-indicator">有未保存修改</span>
<span v-if="message" class="save-message"><CheckCircle :size="18" />{{ message }}</span>
<a href="/?preview=draft" target="_blank"><ArrowSquareOut :size="19" />预览草稿</a>
<button type="button" :disabled="saving" @click="save"><FloppyDisk :size="19" />{{ saving ? '保存中' : '保存草稿' }}</button>
<button class="publish-button" type="button" :disabled="publishing" @click="publish"><UploadSimple :size="19" />{{ publishing ? '发布中' : '发布更新' }}</button>
</div>
</header>
<div v-if="loading" class="admin-loading">正在加载配置</div>
<div v-else-if="document" class="admin-workspace">
<section class="editor-panel">
<div class="panel-heading"><div><span>00</span><h1>公司与品牌</h1></div><small>全站统一信息</small></div>
<div class="form-grid">
<label>官网展示名称<input v-model="document.brand.name" maxlength="40" placeholder="例如:慧遇书院"></label>
<label>公司主体全称<input v-model="document.brand.companyName" maxlength="100" placeholder="营业执照登记名称"></label>
<label>英文名称<input v-model="document.brand.englishName" maxlength="80"></label>
<label>品牌主张<input v-model="document.brand.slogan" maxlength="40"></label>
<label>品牌副文案<input v-model="document.brand.subSlogan" maxlength="80"></label>
<label class="full upload-field">品牌 Logo
<span class="image-input"><img :src="document.brand.logo" alt="当前品牌 Logo"><input type="file" accept="image/png,image/jpeg,image/webp" @change="uploadImage($event, 'brand.logo')"><b>{{ uploadBusy === 'brand.logo' ? '上传中…' : '选择图片' }}</b></span>
<MediaPicker v-model="document.brand.logo" />
</label>
</div>
<div class="panel-heading secondary"><div><span>01</span><h2>英雄区</h2></div><small>首屏核心信息</small></div>
<div class="form-grid">
<label>第一行标题<input v-model="document.hero.titleLine1" maxlength="20"></label>
<label>第二行标题<input v-model="document.hero.titleLine2" maxlength="20"></label>
<label class="full">副标题<input v-model="document.hero.subtitle" maxlength="80"></label>
<label>按钮文字<input v-model="document.hero.cta" maxlength="20"></label>
<label>SEO 标题<input v-model="document.seo.title" maxlength="70"></label>
<label class="full upload-field">首屏背景图
<span class="image-input"><img :src="document.hero.backgroundImage" alt="当前首屏背景"><input type="file" accept="image/png,image/jpeg,image/webp" @change="uploadImage($event, 'hero.backgroundImage')"><b>{{ uploadBusy === 'hero.backgroundImage' ? '上传中…' : '选择图片' }}</b></span>
<MediaPicker v-model="document.hero.backgroundImage" />
</label>
</div>
<div id="teacher-form" class="panel-heading secondary"><div><span>02</span><h2>卢慧老师</h2></div><small>首页展示摘要</small></div>
<div class="form-grid">
<label>姓名<input v-model="document.teacher.name" maxlength="30"></label>
<label>身份说明<input v-model="document.teacher.title" maxlength="80"></label>
<label class="full">简介<textarea v-model="document.teacher.summary" rows="5" maxlength="400" /></label>
<label class="full upload-field">人物肖像
<span class="image-input portrait"><img :src="document.teacher.portrait" :alt="document.teacher.name"><input type="file" accept="image/png,image/jpeg,image/webp" @change="uploadImage($event, 'teacher.portrait')"><b>{{ uploadBusy === 'teacher.portrait' ? '上传中…' : '选择图片' }}</b></span>
<MediaPicker v-model="document.teacher.portrait" />
</label>
</div>
<div class="panel-heading secondary"><div><span>03</span><h2>联系与搜索信息</h2></div><small>页脚和搜索引擎展示</small></div>
<div class="form-grid">
<label>联系电话<input v-model="document.contact.phone" maxlength="30"></label>
<label>联系邮箱<input v-model="document.contact.email" maxlength="100"></label>
<label class="full">公司地址<input v-model="document.contact.address" maxlength="160"></label>
<label class="full">SEO 描述<textarea v-model="document.seo.description" rows="3" maxlength="180" /></label>
</div>
<div id="media" class="media-placeholder">
<ImageSquare :size="32" weight="light" />
<div><strong>本地素材存储</strong><p>一期图片保存在服务器数据卷Logo首屏背景与人物肖像已支持上传配置</p></div>
</div>
</section>
<aside class="preview-panel">
<div class="preview-label"><span>网站实时预览</span><small>{{ user?.displayName }}</small></div>
<div class="preview-window" :style="{ backgroundImage: `url(${document.hero.backgroundImage})` }">
<BrandLogo :src="document.brand.logo" :name="document.brand.name" :english-name="document.brand.englishName" />
<div><h2>{{ document.hero.titleLine1 }}<br>{{ document.hero.titleLine2 }}</h2><p>{{ document.hero.subtitle }}</p><span>{{ document.hero.cta }} </span></div>
</div>
<div class="preview-teacher">
<img :src="document.teacher.portrait" :alt="document.teacher.name">
<div><h3>{{ document.teacher.name }}</h3><p>{{ document.teacher.title }}</p><small>{{ document.teacher.summary }}</small></div>
</div>
</aside>
</div>
</section>
</main>
</template>

58
app/pages/admin/login.vue Normal file
View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import {
PhArrowRight as ArrowRight,
PhEye as Eye,
PhEyeSlash as EyeSlash,
PhLockKey as LockKey,
PhUser as User,
} from '@phosphor-icons/vue'
definePageMeta({ layout: false })
const { data: publicSite } = await useFetch<{ data: { brand: { name: string; englishName: string; logo: string; subSlogan: string } } }>('/api/public/site')
const loginBrand = computed(() => publicSite.value?.data.brand ?? { name: '慧遇书院', englishName: 'HUIYU ACADEMY', logo: '/images/brand-mark-v2.png', subSlogan: '用科技与专业,陪伴每一次心灵的成长' })
useHead(() => ({ title: `后台管理登录|${loginBrand.value.name}` }))
const form = reactive({ username: 'admin', password: '' })
const showPassword = ref(false)
const loading = ref(false)
const error = ref('')
async function login() {
error.value = ''
loading.value = true
try {
await $fetch('/api/auth/login', { method: 'POST', body: form })
await navigateTo('/admin')
} catch (cause: any) {
error.value = cause?.data?.statusMessage || '登录失败,请稍后重试'
} finally {
loading.value = false
}
}
</script>
<template>
<main class="login-page">
<div class="login-brand">
<BrandLogo :src="loginBrand.logo" :name="loginBrand.name" :english-name="loginBrand.englishName" />
<div>
<h1>身心疗愈<br>科技赋能</h1>
<p>{{ loginBrand.subSlogan }}</p>
</div>
</div>
<section class="login-card">
<p class="eyebrow">HUIYU CONTENT SYSTEM</p>
<h2>后台管理登录</h2>
<p class="login-intro">欢迎回来请登录管理员账号</p>
<form @submit.prevent="login">
<label>账号</label>
<div class="field"><User :size="22" /><input v-model.trim="form.username" autocomplete="username" placeholder="请输入账号" required></div>
<label>密码</label>
<div class="field"><LockKey :size="22" /><input v-model="form.password" :type="showPassword ? 'text' : 'password'" autocomplete="current-password" placeholder="请输入密码" required><button type="button" aria-label="切换密码显示" @click="showPassword = !showPassword"><EyeSlash v-if="showPassword" :size="21" /><Eye v-else :size="21" /></button></div>
<p v-if="error" class="form-error" role="alert">{{ error }}</p>
<button class="login-submit" type="submit" :disabled="loading"><span>{{ loading ? '登录中…' : '登录后台' }}</span><ArrowRight :size="21" /></button>
</form>
<p class="security-note">安全连接已启用登录状态受保护</p>
</section>
</main>
</template>

111
app/pages/admin/media.vue Normal file
View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import { PhArrowCounterClockwise, PhCheckCircle, PhFloppyDisk, PhImageSquare, PhTrash, PhUploadSimple } from '@phosphor-icons/vue'
import type { SiteDocument } from '../../../server/utils/site-schema'
definePageMeta({ layout: false })
useHead(() => ({ title: `素材中心|${site.value?.brand.name || '慧遇书院'}` }))
interface MediaAsset {
id: number; key: string; originalName: string; mimeType: string; bytes: number; width: number; height: number
url: string; thumbnailUrl: string; altText: string; source: string; copyright: string
authorizationStatus: 'pending' | 'authorized' | 'restricted'; status: 'active' | 'trash' | 'deleted'; referenced: boolean
createdAt: string; deletedAt: string | null
}
const site = ref<SiteDocument>()
const items = ref<MediaAsset[]>([])
const selectedId = ref<number>()
const view = ref<'active' | 'trash'>('active')
const loading = ref(true), busy = ref(false), message = ref('')
const uploadMeta = reactive({ altText: '', source: '', copyright: '', authorizationStatus: 'pending' as MediaAsset['authorizationStatus'] })
const selected = computed(() => items.value.find(item => item.id === selectedId.value))
onMounted(async () => {
try {
await $fetch('/api/auth/me')
site.value = (await $fetch<{ data: SiteDocument }>('/api/admin/site')).data
await loadItems()
} catch { await navigateTo('/admin/login') }
finally { loading.value = false }
})
async function loadItems() {
const result = await $fetch<{ data: MediaAsset[] }>('/api/admin/media', { query: { status: view.value } })
items.value = result.data
selectedId.value = items.value[0]?.id
}
async function changeView(next: 'active' | 'trash') { view.value = next; message.value = ''; await loadItems() }
async function upload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
busy.value = true; message.value = ''
try {
const body = new FormData(); body.append('file', file)
Object.entries(uploadMeta).forEach(([key, value]) => body.append(key, value))
const result = await $fetch<{ media: MediaAsset }>('/api/admin/media', { method: 'POST', body })
items.value.unshift(result.media); selectedId.value = result.media.id
uploadMeta.altText = ''; uploadMeta.source = ''; uploadMeta.copyright = ''; uploadMeta.authorizationStatus = 'pending'
message.value = '素材已上传并生成 WebP 与缩略图'
} catch (error: any) { message.value = error?.data?.statusMessage || '上传失败' }
finally { busy.value = false; input.value = '' }
}
async function saveMetadata() {
if (!selected.value) return
busy.value = true; message.value = ''
try {
await $fetch(`/api/admin/media/${selected.value.id}`, { method: 'PATCH', body: { altText: selected.value.altText, source: selected.value.source, copyright: selected.value.copyright, authorizationStatus: selected.value.authorizationStatus } })
message.value = '素材信息已保存'
} catch (error: any) { message.value = error?.data?.statusMessage || '保存失败' }
finally { busy.value = false }
}
async function trash() {
if (!selected.value || !window.confirm('确定把该素材移入回收站吗?')) return
busy.value = true; message.value = ''
try { await $fetch(`/api/admin/media/${selected.value.id}`, { method: 'DELETE' }); message.value = '素材已移入回收站'; await loadItems() }
catch (error: any) { message.value = error?.data?.statusMessage || '删除失败' }
finally { busy.value = false }
}
async function restore() {
if (!selected.value) return
busy.value = true
try { await $fetch(`/api/admin/media/${selected.value.id}/restore`, { method: 'POST' }); message.value = '素材已恢复'; await loadItems() }
catch (error: any) { message.value = error?.data?.statusMessage || '恢复失败' }
finally { busy.value = false }
}
const formatBytes = (bytes: number) => bytes < 1024 * 1024 ? `${Math.ceil(bytes / 1024)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`
</script>
<template>
<main class="admin-shell">
<AdminSidebar active="media" :logo="site?.brand.logo" :brand-name="site?.brand.name" :english-name="site?.brand.englishName" />
<section class="admin-main">
<header class="admin-topbar"><div><p>素材中心</p><small>原图WebP 衍生图授权信息与安全回收站</small></div><div class="admin-actions"><span v-if="message" class="save-message"><PhCheckCircle :size="18" />{{ message }}</span><button :class="{ 'publish-button': view === 'active' }" @click="changeView('active')">素材库</button><button :class="{ 'publish-button': view === 'trash' }" @click="changeView('trash')">回收站</button></div></header>
<div v-if="loading" class="admin-loading">正在加载素材</div>
<div v-else class="media-admin-body">
<section v-if="view === 'active'" class="media-upload-panel editor-panel">
<div><PhUploadSimple :size="30" /><strong>上传新图片</strong><small>支持 JPGPNGWebP最大 8MB4000 万像素</small></div>
<div class="media-upload-meta"><input v-model="uploadMeta.altText" placeholder="ALT 描述(建议填写)"><input v-model="uploadMeta.source" placeholder="素材来源"><input v-model="uploadMeta.copyright" placeholder="版权/授权说明"><select v-model="uploadMeta.authorizationStatus"><option value="pending">待核验</option><option value="authorized">已授权</option><option value="restricted">限制使用</option></select></div>
<label class="media-upload-button"><input type="file" accept="image/jpeg,image/png,image/webp" :disabled="busy" @change="upload">{{ busy ? '处理中' : '选择并上传图片' }}</label>
</section>
<div class="media-library-layout">
<section class="media-grid-panel">
<div v-if="!items.length" class="empty-editor"><PhImageSquare :size="38" /><p>{{ view === 'active' ? '尚未上传素材' : '回收站为空' }}</p></div>
<button v-for="item in items" :key="item.id" :class="['media-tile', { active: item.id === selectedId }]" @click="selectedId = item.id"><img :src="item.thumbnailUrl" :alt="item.altText || item.originalName"><span>{{ item.originalName }}</span><small>{{ item.width }}×{{ item.height }} · {{ formatBytes(item.bytes) }}</small><b v-if="item.referenced">使用中</b></button>
</section>
<aside v-if="selected" class="media-detail editor-panel">
<img :src="selected.url" :alt="selected.altText || selected.originalName"><div class="media-detail-meta"><strong>{{ selected.originalName }}</strong><small>{{ selected.mimeType }} · {{ selected.width }}×{{ selected.height }} · {{ formatBytes(selected.bytes) }}</small><a :href="selected.url" target="_blank">打开衍生图</a></div>
<div v-if="view === 'active'" class="form-grid"><label class="full">ALT 描述<input v-model="selected.altText" maxlength="160"></label><label class="full">素材来源<input v-model="selected.source" maxlength="200"></label><label class="full">版权/授权说明<input v-model="selected.copyright" maxlength="200"></label><label class="full">授权状态<select v-model="selected.authorizationStatus"><option value="pending">待核验</option><option value="authorized">已授权</option><option value="restricted">限制使用</option></select></label></div>
<div class="media-detail-actions"><template v-if="view === 'active'"><button :disabled="busy" @click="saveMetadata"><PhFloppyDisk :size="17" />保存信息</button><button class="danger-link" :disabled="busy || selected.referenced" @click="trash"><PhTrash :size="17" />{{ selected.referenced ? '页面使用中' : '移入回收站' }}</button></template><button v-else :disabled="busy" @click="restore"><PhArrowCounterClockwise :size="17" />恢复素材</button></div>
</aside>
</div>
</div>
</section>
</main>
</template>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import { PhCheckCircle, PhFloppyDisk, PhPlus, PhTrash, PhUploadSimple } from '@phosphor-icons/vue'
import { apiErrorMessage } from '~/composables/useUnsavedChanges'
import type { SiteDocument } from '../../../server/utils/site-schema'
definePageMeta({ layout: false })
useHead(() => ({ title: `网站设置|${document.value?.brand.name || '慧遇书院'}` }))
const document = ref<SiteDocument>()
const loading = ref(true), busy = ref(false), message = ref('')
const revisions = ref<Array<{ version: number; action: string; comment: string | null; createdAt: string }>>([])
const auditLogs = ref<Array<{ id: number; action: string; entityType: string; entityKey: string | null; ipAddress: string | null; createdAt: string; operator: string }>>([])
type HealthStatus = { status: string; version: string; checks: { database: string; uploads: string; freeBytes: number } }
const health = ref<HealthStatus>()
const passwords = reactive({ currentPassword: '', newPassword: '', confirmPassword: '' })
const { dirty, markClean } = useUnsavedChanges(document)
onMounted(async () => {
try {
await $fetch('/api/auth/me')
const [siteResult, revisionResult, auditResult, healthResult] = await Promise.all([$fetch<{ data: SiteDocument }>('/api/admin/site'), $fetch<{ data: typeof revisions.value }>('/api/admin/site/revisions'), $fetch<{ data: typeof auditLogs.value }>('/api/admin/audit'), $fetch<HealthStatus>('/api/health')])
document.value = siteResult.data; revisions.value = revisionResult.data; auditLogs.value = auditResult.data; health.value = healthResult
await nextTick(); markClean()
}
catch { await navigateTo('/admin/login') }
finally { loading.value = false }
})
async function save(publish = false) {
if (!document.value) return
busy.value = true; message.value = ''
try {
await $fetch('/api/admin/site', { method: 'PUT', body: document.value })
if (publish) { const result = await $fetch<{ version: number }>('/api/admin/site/publish', { method: 'POST' }); message.value = `已发布 V${result.version}` }
else message.value = '草稿已保存'
markClean()
} catch (error: any) { message.value = apiErrorMessage(error, '保存失败,请检查内容长度') }
finally { busy.value = false }
}
async function restoreVersion(version: number) {
if (!window.confirm(`确定把 V${version} 恢复为当前草稿吗?已发布网站不会立即变化。`)) return
busy.value = true
try { document.value = (await $fetch<{ data: SiteDocument }>(`/api/admin/site/revisions/${version}/restore`, { method: 'POST' })).data; await nextTick(); markClean(); message.value = `V${version} 已恢复为草稿,请检查后再发布` }
catch (error: any) { message.value = error?.data?.statusMessage || '恢复失败' }
finally { busy.value = false }
}
async function changePassword() {
if (passwords.newPassword !== passwords.confirmPassword) { message.value = '两次输入的新密码不一致'; return }
busy.value = true
try {
await $fetch('/api/auth/password', { method: 'PUT', body: { currentPassword: passwords.currentPassword, newPassword: passwords.newPassword } })
passwords.currentPassword = ''; passwords.newPassword = ''; passwords.confirmPassword = ''; message.value = '管理员密码已修改'
} catch (error: any) { message.value = error?.data?.statusMessage || error?.data?.data?.issues?.[0]?.message || '密码修改失败' }
finally { busy.value = false }
}
</script>
<template>
<main class="admin-shell"><AdminSidebar active="settings" :logo="document?.brand.logo" :brand-name="document?.brand.name" :english-name="document?.brand.englishName" />
<section class="admin-main"><header class="admin-topbar"><div><p>网站设置</p><small>公司主体人物资料官方渠道与法律文本</small></div><div class="admin-actions"><span v-if="dirty" class="dirty-indicator">有未保存修改</span><span v-if="message" class="save-message"><PhCheckCircle :size="18" />{{ message }}</span><a href="/luhui?preview=draft" target="_blank">预览人物页</a><button :disabled="busy" @click="save(false)"><PhFloppyDisk :size="18" />保存草稿</button><button class="publish-button" :disabled="busy" @click="save(true)"><PhUploadSimple :size="18" />发布更新</button></div></header>
<div v-if="loading" class="admin-loading">正在加载设置</div>
<div v-else-if="document" class="settings-admin-body">
<section class="editor-panel"><div class="panel-heading"><div><span>01</span><h1>公司主体信息</h1></div><small>正式上线前必须核验</small></div><div class="form-grid">
<label>官网展示名称<input v-model="document.brand.name" placeholder="例如:慧遇书院"></label><label>公司主体全称<input v-model="document.brand.companyName" placeholder="营业执照登记名称"></label><label>统一社会信用代码<input v-model="document.brand.creditCode"></label><label>版权信息<input v-model="document.brand.copyright"></label><label>ICP备案<input v-model="document.brand.icp"></label><label>公安备案<input v-model="document.brand.policeRecord"></label><label class="full">公司介绍<textarea v-model="document.brand.introduction" rows="5" /></label>
</div></section>
<section class="editor-panel"><div class="panel-heading"><div><span>02</span><h2>卢慧老师完整资料</h2></div><small>请以本人确认内容为准</small></div><div class="form-grid">
<label class="full">完整介绍<textarea v-model="document.teacher.fullBio" rows="12" /></label><label class="full">标签(用中文逗号分隔)<input :value="document.teacher.tags.join('')" @input="document.teacher.tags = ($event.target as HTMLInputElement).value.split(/[,]/).map(v => v.trim()).filter(Boolean).slice(0, 6)"></label><label class="full">代表成果(每行一项)<textarea :value="document.teacher.achievements.join('\n')" rows="6" @input="document.teacher.achievements = ($event.target as HTMLTextAreaElement).value.split('\n').map(v => v.trim()).filter(Boolean).slice(0, 12)" /></label>
</div><div class="subsection-heading"><h3>经历与实践</h3><button class="inline-add" @click="document.teacher.experiences.push({ year: '', title: '新经历', description: '' })"><PhPlus :size="17" />新增经历</button></div><div class="repeat-settings"><article v-for="(experience, index) in document.teacher.experiences" :key="index"><div class="form-grid"><label>年份<input v-model="experience.year"></label><label>标题<input v-model="experience.title"></label><label class="full">说明<textarea v-model="experience.description" rows="3" /></label></div><button class="danger-link" @click="document.teacher.experiences.splice(index, 1)"><PhTrash :size="16" />移除</button></article></div><div class="subsection-heading"><h3>外部权威链接</h3><button class="inline-add" @click="document.teacher.externalLinks.push({ label: '新链接', url: '' })"><PhPlus :size="17" />新增链接</button></div><div class="repeat-settings"><article v-for="(link, index) in document.teacher.externalLinks" :key="index"><div class="form-grid"><label>名称<input v-model="link.label"></label><label>链接<input v-model="link.url"></label></div><button class="danger-link" @click="document.teacher.externalLinks.splice(index, 1)"><PhTrash :size="16" />移除</button></article></div></section>
<section class="editor-panel"><div class="panel-heading"><div><span>03</span><h2>官方渠道</h2></div><button class="inline-add" @click="document.channels.push({ name: '新渠道', account: '', url: '', qrCode: '' })"><PhPlus :size="17" />新增渠道</button></div><div class="repeat-settings"><article v-for="(channel, index) in document.channels" :key="index"><div class="form-grid"><label>渠道名称<input v-model="channel.name"></label><label>账号<input v-model="channel.account"></label><label>跳转链接<input v-model="channel.url"></label><label>二维码图片地址<input v-model="channel.qrCode"><MediaPicker v-model="channel.qrCode" label="从素材库选择二维码" /></label></div><button class="danger-link" @click="document.channels.splice(index, 1)"><PhTrash :size="16" />移除</button></article></div></section>
<section class="editor-panel"><div class="panel-heading"><div><span>04</span><h2>法律文本</h2></div><small>纯文本安全展示</small></div><div class="form-grid"><label>隐私政策标题<input v-model="document.legal.privacyTitle"></label><label>服务条款标题<input v-model="document.legal.termsTitle"></label><label class="full">隐私政策<textarea v-model="document.legal.privacyContent" rows="12" /></label><label class="full">服务条款<textarea v-model="document.legal.termsContent" rows="12" /></label></div></section>
<section class="editor-panel"><div class="panel-heading"><div><span>05</span><h2>发布版本</h2></div><small>最近 20 个发布快照</small></div><div class="revision-list"><article v-for="revision in revisions" :key="revision.version"><div><strong>V{{ revision.version }}</strong><small>{{ new Date(revision.createdAt).toLocaleString('zh-CN') }}</small></div><button :disabled="busy" @click="restoreVersion(revision.version)">恢复为草稿</button></article><p v-if="!revisions.length">尚无发布历史</p></div></section>
<section class="editor-panel"><div class="panel-heading"><div><span>06</span><h2>管理员密码</h2></div><small>至少 12 包含大小写数字和特殊字符</small></div><form class="form-grid" @submit.prevent="changePassword"><label>当前密码<input v-model="passwords.currentPassword" type="password" autocomplete="current-password"></label><label>新密码<input v-model="passwords.newPassword" type="password" autocomplete="new-password"></label><label>确认新密码<input v-model="passwords.confirmPassword" type="password" autocomplete="new-password"></label><div class="password-action"><button class="inline-add" :disabled="busy">修改密码</button></div></form></section>
<section class="editor-panel"><div class="panel-heading"><div><span>07</span><h2>系统状态与操作记录</h2></div><small v-if="health" class="health-ok">运行正常 · {{ health.version }} · 可用空间 {{ (health.checks.freeBytes / 1024 / 1024 / 1024).toFixed(1) }} GB</small></div><div class="audit-list"><article v-for="log in auditLogs.slice(0, 20)" :key="log.id"><div><strong>{{ log.action }}</strong><span>{{ log.entityType }}<template v-if="log.entityKey"> · {{ log.entityKey }}</template></span></div><small>{{ log.operator }} · {{ new Date(log.createdAt).toLocaleString('zh-CN') }}</small></article><p v-if="!auditLogs.length">暂无操作记录</p></div></section>
</div>
</section>
</main>
</template>

204
app/pages/index.vue Normal file
View File

@@ -0,0 +1,204 @@
<script setup lang="ts">
import {
PhArrowRight as ArrowRight,
PhBuildings as Buildings,
PhCalendarBlank as CalendarBlank,
PhCaretDown as CaretDown,
PhCpu as Cpu,
PhEnvelopeSimple as EnvelopeSimple,
PhLeaf as Leaf,
PhMapPin as MapPin,
PhPhone as Phone,
PhSealCheck as SealCheck,
PhSparkle as Sparkle,
PhUsersThree as UsersThree,
} from '@phosphor-icons/vue'
const { site, isPreview } = await useSiteDocument()
const config = useRuntimeConfig()
const absoluteUrl = useAbsoluteUrl()
const publishedProducts = computed(() => site.value.products.filter(item => isPreview.value ? item.status !== 'offline' : item.status === 'published'))
const news = computed(() => site.value.articles.filter(item => isPreview.value ? item.status !== 'offline' : item.status === 'published').slice(0, 4))
const team = computed(() => site.value.team.filter(item => isPreview.value ? item.status !== 'offline' : item.status === 'published').slice(0, 4))
const hero = ref<HTMLElement>()
let heroFrame = 0
const absoluteHero = computed(() => absoluteUrl(site.value.hero.backgroundImage))
const contactPhoneHref = computed(() => /[待演示]/.test(site.value.contact.phone) ? '' : `tel:${site.value.contact.phone.replace(/\s/g, '')}`)
const contactEmailHref = computed(() => site.value.contact.email.includes('@') && !site.value.contact.email.includes('演示') ? `mailto:${site.value.contact.email}` : '')
const usesDefaultHero = computed(() => site.value.hero.backgroundImage === '/images/hero-bg-v3.webp')
const previewTo = (path: string) => isPreview.value ? { path, query: { preview: 'draft' } } : path
useSeoMeta({
title: () => site.value?.seo.title,
description: () => site.value?.seo.description,
ogTitle: () => site.value?.seo.title,
ogDescription: () => site.value?.seo.description,
ogImage: () => absoluteHero.value,
})
useHead(() => ({
link: usesDefaultHero.value ? [{ rel: 'preload', as: 'image', href: '/images/hero-bg-v3.avif', type: 'image/avif' }] : [],
script: [{ type: 'application/ld+json', textContent: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Organization', name: site.value.brand.companyName, url: config.public.siteUrl, logo: absoluteUrl(site.value.brand.logo), description: site.value.seo.description, email: site.value.contact.email, telephone: site.value.contact.phone }) }],
}))
const philosophies = [
{ title: '觉察', subtitle: '看见自己,是改变的开始', icon: Sparkle },
{ title: '关系', subtitle: '关系是心灵的镜子', icon: UsersThree },
{ title: '成长', subtitle: '在觉察与实践中持续成长', icon: Leaf },
]
const updateHeroScroll = () => {
heroFrame = 0
hero.value?.style.setProperty('--hero-scroll', String(Math.min(1, window.scrollY / Math.max(window.innerHeight, 1))))
}
const onHeroScroll = () => { if (!heroFrame) heroFrame = requestAnimationFrame(updateHeroScroll) }
const onHeroPointer = (event: PointerEvent) => {
if (!hero.value) return
const bounds = hero.value.getBoundingClientRect()
hero.value.style.setProperty('--pointer-x', String((event.clientX - bounds.left) / bounds.width - 0.5))
hero.value.style.setProperty('--pointer-y', String((event.clientY - bounds.top) / bounds.height - 0.5))
}
onMounted(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
updateHeroScroll()
window.addEventListener('scroll', onHeroScroll, { passive: true })
hero.value?.addEventListener('pointermove', onHeroPointer, { passive: true })
})
onBeforeUnmount(() => {
if (import.meta.client) window.removeEventListener('scroll', onHeroScroll)
hero.value?.removeEventListener('pointermove', onHeroPointer)
if (heroFrame) cancelAnimationFrame(heroFrame)
})
</script>
<template>
<main id="main-content" class="site-shell">
<section id="home" ref="hero" class="hero">
<picture class="hero-media" aria-hidden="true">
<source v-if="usesDefaultHero" srcset="/images/hero-bg-v3.avif" type="image/avif">
<source v-if="usesDefaultHero" srcset="/images/hero-bg-v3.webp" type="image/webp">
<img :src="site.hero.backgroundImage" alt="" width="1672" height="941" fetchpriority="high" decoding="async">
</picture>
<SiteHeader :logo="site.brand.logo" :brand-name="site.brand.name" :english-name="site.brand.englishName" />
<div class="hero-copy">
<h1><span>{{ site.hero.titleLine1 }}</span><span>{{ site.hero.titleLine2 }}</span></h1>
<p class="hero-subtitle">{{ site.hero.subtitle }}</p>
<a href="#about" class="primary-button">{{ site.hero.cta }} <ArrowRight :size="20" /></a>
</div>
<a class="scroll-cue" href="#about" aria-label="向下滚动了解更多">
<span>向下探索</span>
<CaretDown :size="22" />
</a>
<div class="hero-wave" aria-hidden="true" />
</section>
<RevealSection id="about" class="about section-pad">
<div class="section-heading centered">
<p class="eyebrow">ABOUT HUIYU</p>
<h2>{{ site.about.title }}</h2>
<p>{{ site.about.summary }}</p>
</div>
<div class="about-layout">
<div class="about-visual">
<img src="/images/hero-bg-v3.webp" alt="晨光中的湖面与远山" width="3344" height="1882" loading="lazy" decoding="async">
</div>
<div class="metric-grid">
<article v-for="(metric, index) in site.about.metrics" :key="metric.label" class="metric-card" :style="{ '--delay': `${index * 90}ms` }">
<component :is="index === 0 ? Leaf : index === 1 ? UsersThree : CalendarBlank" :size="28" weight="light" />
<AnimatedMetric :value="metric.value" />
<span>{{ metric.label }}</span>
<small>{{ metric.description }}</small>
</article>
</div>
</div>
</RevealSection>
<RevealSection id="teacher" class="teacher-section section-pad">
<div class="teacher-card">
<div class="teacher-portrait">
<img :src="site.teacher.portrait" :alt="site.teacher.name" width="536" height="804" loading="lazy" decoding="async">
</div>
<div class="teacher-copy">
<p class="eyebrow">CORE MENTOR</p>
<h2>{{ site.teacher.name }}</h2>
<p class="teacher-title">{{ site.teacher.title }}</p>
<p class="teacher-summary">{{ site.teacher.summary }}</p>
<div class="tag-list">
<span v-for="tag in site.teacher.tags" :key="tag">{{ tag }}</span>
</div>
<NuxtLink :to="previewTo('/luhui')" class="text-link">了解更多 <ArrowRight :size="18" /></NuxtLink>
</div>
</div>
</RevealSection>
<RevealSection class="philosophy section-pad">
<div class="section-heading centered">
<p class="eyebrow">PHILOSOPHY</p>
<h2>理念与方法</h2>
</div>
<div class="philosophy-grid">
<article v-for="(item, index) in philosophies" :key="item.title" class="philosophy-card">
<span class="philosophy-index">0{{ index + 1 }}</span>
<component :is="item.icon" :size="30" weight="light" />
<h3>{{ item.title }}</h3>
<p>{{ item.subtitle }}</p>
</article>
</div>
</RevealSection>
<RevealSection id="products" class="products section-pad">
<div class="section-heading split-heading">
<div><p class="eyebrow">COURSES & PRODUCTS</p><h2>课程与产品</h2></div>
<p>围绕家庭关系个人成长与日常实践提供清晰可持续的陪伴路径</p>
</div>
<div class="product-grid">
<NuxtLink v-for="product in publishedProducts" :key="product.slug" :to="previewTo(`/products/${product.slug}`)" class="product-card">
<img :src="product.image" :alt="product.title" width="1536" height="1024" loading="lazy" decoding="async">
<div><h3>{{ product.title }}</h3><p>{{ product.subtitle }}</p></div>
<ArrowRight :size="20" />
</NuxtLink>
</div>
</RevealSection>
<RevealSection id="official" class="official section-pad">
<div class="section-heading centered"><p class="eyebrow">OFFICIAL INFORMATION</p><h2>官方信息</h2></div>
<div class="official-grid">
<article><Buildings :size="32" weight="light" /><h3>公司主体</h3><p>统一维护并对外公示</p></article>
<article><UsersThree :size="32" weight="light" /><h3>官方老师</h3><p>认证身份与官方资料</p></article>
<article><SealCheck :size="32" weight="light" /><h3>官方账号</h3><p>官方平台与联系方式</p></article>
<article><Cpu :size="32" weight="light" /><h3>科技产品</h3><p>智能工具与服务入口</p></article>
</div>
<div v-if="site.channels.length" class="authority-links centered-links"><h2>官方渠道</h2><template v-for="channel in site.channels" :key="channel.name"><a v-if="channel.url" :href="channel.url" target="_blank" rel="noopener noreferrer">{{ channel.name }} · {{ channel.account }}</a><span v-else class="disabled-channel" aria-disabled="true">{{ channel.name }} · {{ channel.account }}</span></template></div>
</RevealSection>
<RevealSection v-if="team.length" class="team-section section-pad">
<div class="section-heading centered"><p class="eyebrow">COMPANION TEAM</p><h2>陪伴团队</h2></div>
<div class="team-grid compact-team"><article v-for="member in team" :key="member.slug"><img :src="member.avatar" :alt="member.name" width="1536" height="1024" loading="lazy" decoding="async"><div><h3>{{ member.name }}</h3><p class="member-role">{{ member.role }}</p><p>{{ member.summary }}</p></div></article></div>
<div class="center-action"><NuxtLink :to="previewTo('/team')" class="text-link">查看团队介绍 <ArrowRight :size="18" /></NuxtLink></div>
</RevealSection>
<RevealSection id="news" class="news-section section-pad">
<div class="news-panel">
<div class="news-feature">
<p class="eyebrow">BRAND NEWS</p>
<h2>品牌动态</h2>
<p>发布官方声明品牌进展与课程信息建立长期可信的官方信息源</p>
</div>
<div class="news-list">
<NuxtLink v-for="item in news" :key="item.slug" :to="previewTo(item.type === 'statement' ? `/statements/${item.slug}` : `/news/${item.slug}`)"><span>{{ item.title }}</span><time>{{ item.publishedAt }}</time><ArrowRight :size="18" /></NuxtLink>
</div>
</div>
</RevealSection>
<footer id="contact" class="site-footer">
<div class="footer-brand"><BrandLogo :src="site.brand.logo" :name="site.brand.name" :english-name="site.brand.englishName" /><p>{{ site.brand.subSlogan }}</p></div>
<div class="footer-contact">
<p><Phone :size="18" /><a v-if="contactPhoneHref" :href="contactPhoneHref">{{ site.contact.phone }}</a><span v-else>{{ site.contact.phone }}</span></p>
<p><EnvelopeSimple :size="18" /><a v-if="contactEmailHref" :href="contactEmailHref">{{ site.contact.email }}</a><span v-else>{{ site.contact.email }}</span></p>
<p><MapPin :size="18" /> {{ site.contact.address }}</p>
</div>
<div class="footer-meta"><span>{{ site.brand.copyright }}</span><span><NuxtLink to="/privacy">隐私政策</NuxtLink> · <NuxtLink to="/terms">服务条款</NuxtLink> · <FilingLinks :icp="site.brand.icp" :police-record="site.brand.policeRecord" /></span></div>
</footer>
</main>
</template>

21
app/pages/luhui.vue Normal file
View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
const { site } = await useSiteDocument()
const absoluteUrl = useAbsoluteUrl()
useSeoMeta({ title: () => `${site.value.teacher.name}${site.value.brand.name}`, description: () => site.value.teacher.summary, ogTitle: () => site.value.teacher.name, ogDescription: () => site.value.teacher.summary, ogImage: () => absoluteUrl(site.value.teacher.portrait) })
useHead(() => ({ script: [{ type: 'application/ld+json', textContent: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Person', name: site.value.teacher.name, description: site.value.teacher.summary, image: absoluteUrl(site.value.teacher.portrait), jobTitle: site.value.teacher.title, worksFor: { '@type': 'Organization', name: site.value.brand.companyName } }) }] }))
</script>
<template>
<PublicPageLayout :site="site">
<section class="detail-hero teacher-detail-hero">
<div><p class="eyebrow">CORE MENTOR</p><h1>{{ site.teacher.name }}</h1><p>{{ site.teacher.title }}</p></div>
<img :src="site.teacher.portrait" :alt="site.teacher.name" width="536" height="804" decoding="async">
</section>
<section class="detail-content teacher-detail-content">
<article><h2>人物介绍</h2><p class="long-copy">{{ site.teacher.fullBio }}</p></article>
<aside><h3>核心方向</h3><div class="tag-list"><span v-for="tag in site.teacher.tags" :key="tag">{{ tag }}</span></div><h3>核心成果</h3><ul><li v-for="item in site.teacher.achievements" :key="item">{{ item }}</li></ul></aside>
</section>
<section class="timeline-section section-pad"><div class="section-heading centered"><p class="eyebrow">EXPERIENCE</p><h2>经历与实践</h2></div><div class="timeline-list"><article v-for="item in site.teacher.experiences" :key="`${item.year}-${item.title}`"><time>{{ item.year }}</time><div><h3>{{ item.title }}</h3><p>{{ item.description }}</p></div></article></div></section>
<section v-if="site.teacher.externalLinks.length" class="authority-links"><h2>外部权威链接</h2><a v-for="link in site.teacher.externalLinks" :key="link.url" :href="link.url" target="_blank" rel="noopener noreferrer">{{ link.label }} </a></section>
</PublicPageLayout>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
const route = useRoute(); const { site, isPreview } = await useSiteDocument(); const absoluteUrl = useAbsoluteUrl()
const article = computed(() => site.value.articles.find(item => item.slug === route.params.slug && (isPreview.value ? item.status !== 'offline' : item.status === 'published') && item.type !== 'statement'))
const related = computed(() => site.value.articles.filter(item => item.slug !== route.params.slug && item.type !== 'statement' && (isPreview.value ? item.status !== 'offline' : item.status === 'published')).slice(0, 3))
if (!article.value) throw createError({ statusCode: 404, statusMessage: '未找到该内容' })
useSeoMeta({ title: () => `${article.value!.title}${site.value.brand.name}`, description: () => article.value!.summary, ogTitle: () => article.value!.title, ogDescription: () => article.value!.summary, ogImage: () => absoluteUrl(article.value!.cover), articlePublishedTime: () => article.value!.publishedAt })
useHead(() => ({ script: [{ type: 'application/ld+json', textContent: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Article', headline: article.value!.title, description: article.value!.summary, datePublished: article.value!.publishedAt, image: absoluteUrl(article.value!.cover), publisher: { '@type': 'Organization', name: site.value.brand.companyName, logo: { '@type': 'ImageObject', url: absoluteUrl(site.value.brand.logo) } } }) }] }))
</script>
<template><PublicPageLayout :site="site"><article v-if="article" class="story-page"><NuxtLink class="detail-back" :to="{ path: '/news', query: isPreview ? { preview: 'draft' } : {} }">← 返回品牌动态</NuxtLink><header><p class="eyebrow">BRAND NEWS</p><h1>{{ article.title }}</h1><p>{{ article.publishedAt }}<span v-if="article.sourceName"> · {{ article.sourceName }}</span></p><img :src="article.cover" :alt="article.title" width="1536" height="1024" decoding="async"></header><div class="story-body"><p class="lead">{{ article.summary }}</p><p class="long-copy">{{ article.content }}</p><a v-if="article.sourceUrl" :href="article.sourceUrl" target="_blank" rel="noopener noreferrer" class="text-link">查看原文 ↗</a></div><aside v-if="related.length" class="related-content"><p class="eyebrow">RELATED</p><h2>继续阅读</h2><NuxtLink v-for="item in related" :key="item.slug" :to="{ path: `/news/${item.slug}`, query: isPreview ? { preview: 'draft' } : {} }"><span>{{ item.title }}</span><time>{{ item.publishedAt }}</time></NuxtLink></aside></article></PublicPageLayout></template>

6
app/pages/news/index.vue Normal file
View File

@@ -0,0 +1,6 @@
<script setup lang="ts">
const { site, isPreview } = await useSiteDocument()
const articles = computed(() => site.value.articles.filter(item => (isPreview.value ? item.status !== 'offline' : item.status === 'published') && item.type !== 'statement'))
useSeoMeta({ title: () => `品牌动态|${site.value.brand.name}`, description: () => `${site.value.brand.name}品牌动态与媒体信息。` })
</script>
<template><PublicPageLayout :site="site"><section class="listing-hero"><p class="eyebrow">BRAND NEWS</p><h1>品牌动态</h1><p>记录品牌进展课程信息与公开报道</p></section><section class="article-list"><NuxtLink v-for="item in articles" :key="item.slug" :to="{ path: `/news/${item.slug}`, query: isPreview ? { preview: 'draft' } : {} }"><img :src="item.cover" :alt="item.title" width="1536" height="1024" loading="lazy" decoding="async"><div><time>{{ item.publishedAt }}</time><h2>{{ item.title }}</h2><p>{{ item.summary }}</p></div><span></span></NuxtLink></section></PublicPageLayout></template>

2
app/pages/privacy.vue Normal file
View File

@@ -0,0 +1,2 @@
<script setup lang="ts">const { site } = await useSiteDocument(); useSeoMeta({ title: () => `${site.value.legal.privacyTitle}${site.value.brand.name}` })</script>
<template><PublicPageLayout :site="site"><article class="legal-page"><p class="eyebrow">LEGAL</p><h1>{{ site.legal.privacyTitle }}</h1><p class="long-copy">{{ site.legal.privacyContent }}</p></article></PublicPageLayout></template>

View File

@@ -0,0 +1,10 @@
<script setup lang="ts">
const route = useRoute()
const { site, isPreview } = await useSiteDocument()
const absoluteUrl = useAbsoluteUrl()
const product = computed(() => site.value.products.find(item => item.slug === route.params.slug && (isPreview.value ? item.status !== 'offline' : item.status === 'published')))
if (!product.value) throw createError({ statusCode: 404, statusMessage: '未找到该课程或产品' })
useSeoMeta({ title: () => `${product.value!.title}${site.value.brand.name}`, description: () => product.value!.summary, ogTitle: () => product.value!.title, ogDescription: () => product.value!.summary, ogImage: () => absoluteUrl(product.value!.image) })
useHead(() => ({ script: [{ type: 'application/ld+json', textContent: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Course', name: product.value!.title, description: product.value!.summary, provider: { '@type': 'Organization', name: site.value.brand.companyName, url: absoluteUrl('/') }, image: absoluteUrl(product.value!.image) }) }] }))
</script>
<template><PublicPageLayout :site="site"><article v-if="product" class="story-page"><NuxtLink class="detail-back" :to="{ path: '/products', query: isPreview ? { preview: 'draft' } : {} }">← 返回课程与产品</NuxtLink><header><p class="eyebrow">{{ product.type === 'tool' ? 'TECH PRODUCT' : 'COURSE' }}</p><h1>{{ product.title }}</h1><p>{{ product.subtitle }}</p><img :src="product.image" :alt="product.title" width="1536" height="1024" loading="eager" decoding="async"></header><div class="story-body"><p class="lead">{{ product.summary }}</p><p class="long-copy">{{ product.content }}</p><a class="primary-button" href="/#contact">咨询了解</a></div></article></PublicPageLayout></template>

View File

@@ -0,0 +1,6 @@
<script setup lang="ts">
const { site, isPreview } = await useSiteDocument()
const products = computed(() => site.value.products.filter(item => isPreview.value ? item.status !== 'offline' : item.status === 'published'))
useSeoMeta({ title: () => `课程与产品|${site.value.brand.name}`, description: () => `${site.value.brand.name}课程、服务与科技产品。`, ogTitle: () => `课程与产品|${site.value.brand.name}`, ogDescription: () => `${site.value.brand.name}课程、服务与科技产品。` })
</script>
<template><PublicPageLayout :site="site"><section class="listing-hero"><p class="eyebrow">COURSES & PRODUCTS</p><h1>课程与产品</h1><p>围绕关系成长与日常实践提供清晰可持续的支持路径</p></section><section class="listing-grid"><NuxtLink v-for="item in products" :key="item.slug" :to="{ path: `/products/${item.slug}`, query: isPreview ? { preview: 'draft' } : {} }" class="listing-card"><img :src="item.image" :alt="item.title" width="1536" height="1024" loading="lazy" decoding="async"><div><small>{{ item.type === 'tool' ? '科技工具' : '成长课程' }}</small><h2>{{ item.title }}</h2><p>{{ item.summary || item.subtitle }}</p><span>查看详情 </span></div></NuxtLink></section></PublicPageLayout></template>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
const route = useRoute(); const { site, isPreview } = await useSiteDocument()
const article = computed(() => site.value.articles.find(item => item.slug === route.params.slug && (isPreview.value ? item.status !== 'offline' : item.status === 'published') && item.type === 'statement'))
if (!article.value) throw createError({ statusCode: 404, statusMessage: '未找到该声明' })
useSeoMeta({ title: () => `${article.value!.title}${site.value.brand.name}`, description: () => article.value!.summary })
</script>
<template><PublicPageLayout :site="site"><article v-if="article" class="story-page statement-page"><NuxtLink class="detail-back" :to="{ path: '/statements', query: isPreview ? { preview: 'draft' } : {} }">← 返回官方声明</NuxtLink><header><p class="eyebrow">OFFICIAL STATEMENT</p><small>{{ article.statementNo }}</small><h1>{{ article.title }}</h1><p>{{ article.publishedAt }}</p></header><div class="story-body"><p class="lead">{{ article.summary }}</p><p class="long-copy">{{ article.content }}</p></div></article></PublicPageLayout></template>

View File

@@ -0,0 +1,6 @@
<script setup lang="ts">
const { site, isPreview } = await useSiteDocument()
const statements = computed(() => site.value.articles.filter(item => (isPreview.value ? item.status !== 'offline' : item.status === 'published') && item.type === 'statement'))
useSeoMeta({ title: () => `官方声明|${site.value.brand.name}`, description: () => `${site.value.brand.name}官方声明与重要信息。` })
</script>
<template><PublicPageLayout :site="site"><section class="listing-hero"><p class="eyebrow">OFFICIAL STATEMENTS</p><h1>官方声明</h1><p>统一发布重要说明帮助访问者辨别可信信息</p></section><section class="statement-list"><NuxtLink v-for="item in statements" :key="item.slug" :to="{ path: `/statements/${item.slug}`, query: isPreview ? { preview: 'draft' } : {} }"><div><small>{{ item.statementNo }}</small><h2>{{ item.title }}</h2><p>{{ item.summary }}</p></div><time>{{ item.publishedAt }}</time></NuxtLink></section></PublicPageLayout></template>

6
app/pages/team.vue Normal file
View File

@@ -0,0 +1,6 @@
<script setup lang="ts">
const { site, isPreview } = await useSiteDocument()
const members = computed(() => site.value.team.filter(item => isPreview.value ? item.status !== 'offline' : item.status === 'published'))
useSeoMeta({ title: () => `陪伴团队|${site.value.brand.name}`, description: () => `${site.value.brand.name}陪伴团队。` })
</script>
<template><PublicPageLayout :site="site"><section class="listing-hero"><p class="eyebrow">COMPANION TEAM</p><h1>陪伴团队</h1><p>以专业温暖和清晰的协作为每一段成长提供支持</p></section><section class="team-grid"><article v-for="member in members" :key="member.slug"><img :src="member.avatar" :alt="member.name" width="1536" height="1024" loading="lazy" decoding="async"><div><h2>{{ member.name }}</h2><p class="role">{{ member.role }}</p><p>{{ member.summary }}</p><div class="tag-list"><span v-for="tag in member.specialties" :key="tag">{{ tag }}</span></div></div></article></section></PublicPageLayout></template>

2
app/pages/terms.vue Normal file
View File

@@ -0,0 +1,2 @@
<script setup lang="ts">const { site } = await useSiteDocument(); useSeoMeta({ title: () => `${site.value.legal.termsTitle}${site.value.brand.name}` })</script>
<template><PublicPageLayout :site="site"><article class="legal-page"><p class="eyebrow">LEGAL</p><h1>{{ site.legal.termsTitle }}</h1><p class="long-copy">{{ site.legal.termsContent }}</p></article></PublicPageLayout></template>

9
app/utils/filing.ts Normal file
View File

@@ -0,0 +1,9 @@
export const MIIT_FILING_URL = 'https://beian.miit.gov.cn'
export const MPS_FILING_URL = 'https://beian.mps.gov.cn/'
export function getPoliceFilingUrl(record: string) {
const recordCode = record.match(/\d{12,20}/)?.[0]
return recordCode
? `https://www.beian.gov.cn/portal/registerSystemInfo?recordcode=${recordCode}`
: MPS_FILING_URL
}

1
backups/.gitkeep Normal file
View File

@@ -0,0 +1 @@

37
compose.yml Normal file
View File

@@ -0,0 +1,37 @@
name: huiyu-official-site
services:
web:
build:
context: .
dockerfile: Dockerfile
image: huiyu-official-site:${IMAGE_TAG:-phase1}
init: true
restart: unless-stopped
ports:
- "${APP_BIND_IP:-127.0.0.1}:${APP_PORT:-3000}:3000"
env_file:
- .env.docker
environment:
NUXT_DATABASE_PATH: /app/data/db/huiyu.sqlite
NUXT_PUBLIC_SITE_URL: ${SITE_URL:-http://localhost:3000}
APP_VERSION: ${IMAGE_TAG:-phase1}
volumes:
- huiyu_data:/app/data
- ${BACKUP_HOST_DIR:-./backups}:/app/offsite-backups
read_only: true
tmpfs:
- /tmp:size=64m
security_opt:
- no-new-privileges:true
stop_grace_period: 20s
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 10s
timeout: 5s
retries: 6
start_period: 20s
volumes:
huiyu_data:
name: huiyu_official_site_data

View File

@@ -0,0 +1,152 @@
# 卢慧|百科内容清洗稿
> 用途:官网人物资料与后续内容策划的基础素材
> 来源:百度百科词条“卢慧(慧遇书院创始人、心理咨询师)”离线网页
> 词条编号65761247
> 页面显示更新时间2026-06-12 18:17:56
> 清洗日期2026-08-10
> 原始页面https://baike.baidu.com/item/卢慧/65761247
## 一、人物基础信息
| 字段 | 内容 | 来源说明 |
| --- | --- | --- |
| 中文名 | 卢慧 | 百科人物卡 |
| 字 | 蝶梦 | 百科概述 |
| 常用称呼 | 蝶梦老师 | 百科人物卡;标注为本人参与编辑 |
| 国籍 | 中国 | 百科人物卡 |
| 出生地 | 安徽省合肥市 | 百科人物卡;标注为本人参与编辑 |
| 身份定位 | 家庭教育与心理学研究者、心理咨询师、教育实践者 | 依据百科概述和人物卡归并 |
| 现任/发起身份 | 慧遇书院创始人OPEN-MIND 数字生命与心智模式实验室联合发起人 | 百科概述 |
| 本科 | 中国科学技术大学电子工程与信息科学系 | 百科概述与“教育背景”章节 |
| 硕士 | 香港中文大学系统工程硕士 | 百科概述;章节中表述为“供应链与物流管理硕士”,两处口径需要核对 |
| 博士 | 西班牙 UCAM 大学心理学博士 | 百科概述与“教育背景”章节 |
| 博士后相关经历 | 于剑桥大学哈默顿学院完成博士后研究 | 百科概述章节中表述为“博士后发展计划2023两处性质不同需要核对 |
## 二、官网人物简介候选稿
卢慧字蝶梦家庭教育与心理学研究者、心理咨询师、教育实践者慧遇书院创始人OPEN-MIND 数字生命与心智模式实验室联合发起人。
她本科毕业于中国科学技术大学电子工程与信息科学系,后赴香港中文大学和西班牙 UCAM 大学继续学习,并参与剑桥大学哈默顿学院博士后发展计划。长期从事家庭教育、心理成长及东方智慧与现代心理学融合的实践探索。
> 本段已去除“科学世家”“核物理专家之女”等与官网核心专业形象关联较弱的家庭背景描述;学历及项目性质须在正式发布前以证书或校方材料核验。
## 三、完整内容整理
### 1. 人物概述
卢慧,字蝶梦,女,家庭教育与心理学研究者、心理咨询师,人力资源和社会保障部家庭(组织)系统排列指导师、心灵导师,安徽合肥人。
百科记载其出生于科学世家,父亲为核物理专家,自幼成长于中国科学技术大学校园。本科毕业于中国科学技术大学电子工程与信息科学系,获香港中文大学系统工程硕士学位、西班牙 UCAM 大学心理学博士学位,并称其于剑桥大学哈默顿学院完成博士后研究。
现为慧遇书院创始人、OPEN-MIND 数字生命与心智模式实验室联合发起人,长期从事家庭教育与心理成长实践工作,探索东方智慧的科学解读、西方心理学的本土化实践及家庭场景应用。
### 2. 教育背景
#### 基础学科阶段1991—2005
- 中国科学技术大学:电子工程与信息科学学士。
- 香港中文大学:供应链与物流管理硕士。
#### 心理学探索阶段2006—2025
- 西班牙 UCAM 大学:心理学博士。
- 博士论文:《慧能公式与心智成长融合研究》。
- 提出“心理能量转化三阶模型”:觉知 → 破局 → 明心。
- 创立“慧式心理学”,亦称“慧学五论”:稳定论、协和论、突变论、数字论、进化论,关注开悟相关心理现象对现实生活场景的影响。
- 剑桥大学 Homerton College博士后发展计划2023
#### 交叉研究阶段2007—2025
- 元宇宙心理学数字生命计划2023 年 5 月)。
- 东方智慧与西方心理学融合实践2007—2025
### 3. 理论体系
#### 慧能心智成长理论
- 核心公式(百科原文称“慧能定律”):`Hmax = log₂N`
- 内在力学:借鉴高能物理概念解释心理内驱力机制。
- 代际能量场:以“量子纠缠”作为隐喻,构建家庭关系干预模型。
- 心智状态的流体动力学模拟:借助流体力学模型,将个体心理状态描述为流动、可变化的元素。
#### 科研实践
- 心智成长的数理化标准研究2024
- 科幻文学心理学实验2024
### 4. 教育实践
#### 慧遇书院课程体系
- 基础层:
- 《觉知之门》:认知重塑。
- 《破局之力》:行为干预。
- 《明心之道》:价值重构。
- 应用层:
- 《家庭关系训练营》:百科原文称“解决代际创伤”。
- 《人本智慧学》线上大实修课:百科原文称“解决理论落地实践”。
#### 行业人才培养
- 支持持证疗愈师创建本地化工作室。
- 形成“蝶梦家庭支持联盟”等区域协作网络。
### 5. 社会活动
2022 年 9 月 28 日,由深圳市卫生健康发展研究和数据管理中心主办的第十二期“四季论坛”通过网络直播举行,卢慧作为嘉宾参与活动。
### 6. 资质与认证
| 资质名称 | 百科所列证书编号 | 发布前处理建议 |
| --- | --- | --- |
| 心理咨询师(二级) | 1217000008202435 | 核验证书原件、颁发单位全称、发证时间及当前有效性 |
| 心灵导师 | PXGLSZ13030010 | “心灵导师”并非国家职业资格规范称谓,官网不宜冠以“人社部认证”直至完成官方查询 |
| 家庭(组织)系统排列指导师 | PXGLSZ13030007 | 核验证书原件、培训或评价机构、证书性质与查询渠道 |
## 四、来源与可信度分层
### A 级:可作为外部活动事实线索
1. 《人工智能与元宇宙赋能智慧医疗——市健研数管中心成功举办第十二期“四季论坛”》
来源:深圳市卫生健康发展研究。
2. 《探寻心智产业的中国范式Open Mind 心智模式实验室发布〈慧能定律〉》
来源:深圳新闻网。
### B 级:机构自述,可作为品牌资料但不能替代独立证明
3. 《慧遇书院创始人卢慧》
来源:慧遇书院。
### C 级:证书图片或本人参与编辑内容,必须核验原件与发证主体
4. 家庭(组织)系统排列指导师证书图片。
5. 心灵导师证书图片。
6. 心理咨询师二级证书图片。
7. 百科标注:“该部分内容由本人参与编辑完善”。
## 五、正式官网发布前必须核验
1. 香港中文大学硕士专业的准确名称:概述写“系统工程硕士”,正文写“供应链与物流管理硕士”。
2. 剑桥大学经历的准确性质:是已完成博士后研究、博士后发展计划,还是短期项目;不得混用。
3. UCAM 心理学博士学位、论文名称及授予年份。
4. “慧式心理学”“慧能定律”“心理能量转化三阶模型”等名称是否有论文、出版物、著作权或其他可查成果。
5. 三项证书的颁发机构、证书性质、发证时间、查询渠道和当前有效性。
6. “心理咨询师二级”是否为原国家职业资格体系下的有效历史证书,以及官网允许使用的准确称谓。
7. “专家”“科学解读”“科研实践”等词语是否具备足够证据;无证据时改为“研究者”“实践探索”“课程方法”。
8. 课程的效果描述不得写成保证性结果;“解决代际创伤”等表述建议改为“关注家庭关系与代际议题”。
## 六、已删除的网页噪声
- 百度导航、搜索框、登录、订阅、编辑、纠错和播报按钮。
- 多义词人物列表、相关推荐、广告及无关脚本样式。
- 浏览次数、编辑次数、贡献者排行等百科平台数据。
- 与人物资料无关的装饰图片、广告图片和网页组件资源。
## 七、已保留图片
- 人物肖像:`/public/images/人物/卢慧-百科肖像.jpg`
- 原图尺寸536 × 804 像素。
- 来源:离线百科页面配套资源。
- 使用提醒:百度百科图片并不等于已取得官网商业使用授权;正式上线前需确认原始摄影版权及肖像使用授权。

View File

@@ -0,0 +1,229 @@
{
"brand": {
"name": "慧遇书院",
"englishName": "HUIYU ACADEMY",
"logo": "/images/brand-mark-v2.png",
"slogan": "身心疗愈 · 科技赋能",
"subSlogan": "用科技与专业,陪伴每一次心灵的成长",
"companyName": "慧遇书院",
"creditCode": "统一社会信用代码待后台配置",
"introduction": "慧遇书院关注家庭教育、心理成长与数字化陪伴,通过内容、课程和智能工具,为个人与家庭提供清晰、温和、可持续的成长支持。本段为官网演示文案,正式上线前需由负责人确认。",
"copyright": "© 2026 慧遇书院 版权所有",
"icp": "ICP备案信息待配置",
"policeRecord": "公安备案信息待配置"
},
"about": {
"title": "关于慧遇书院",
"summary": "连接东方智慧、现代心理学与数字技术,让专业内容更容易理解,让日常练习更容易坚持,让每一次成长都有温度地发生。",
"metrics": [
{ "value": "2007", "label": "实践起点", "description": "持续探索成长议题" },
{ "value": "3条", "label": "成长路径", "description": "觉察、关系与行动" },
{ "value": "6项", "label": "课程与工具", "description": "覆盖学习与陪伴" }
]
},
"teacher": {
"name": "卢慧老师",
"title": "家庭教育与心理学研究者|心理咨询师|慧遇书院创始人",
"summary": "长期从事家庭教育与心理成长实践,关注个体、关系与家庭场景中的真实需要,探索东方智慧与现代心理学的融合应用。",
"portrait": "/images/人物/卢慧-百科肖像.jpg",
"tags": ["家庭教育", "心理成长", "关系支持", "东方智慧", "教育实践"],
"fullBio": "卢慧字蝶梦家庭教育与心理学研究者、心理咨询师、教育实践者慧遇书院创始人、OPEN-MIND 数字生命与心智模式实验室联合发起人。\n\n据百度百科资料她本科毕业于中国科学技术大学电子工程与信息科学系后赴香港中文大学和西班牙 UCAM 大学继续学习,并参与剑桥大学哈默顿学院博士后发展计划。她长期关注家庭教育、心理成长以及东方智慧与现代心理学的融合实践。\n\n本页依据百度百科离线资料整理。学历、项目性质、专业资质及相关理论名称仍需在正式发布前以证书、校方材料或权威公开来源逐项核验。",
"experiences": [
{ "year": "2024", "title": "开展心智成长相关实践探索", "description": "围绕心智成长、数理化表达与科幻文学心理学等主题进行实践探索;具体成果形式待正式资料核验。" },
{ "year": "2023", "title": "参与跨学科项目", "description": "百科资料记载其参与剑桥大学哈默顿学院博士后发展计划,并开展数字生命相关探索;项目性质待核验。" },
{ "year": "2022", "title": "参与第十二期“四季论坛”", "description": "作为嘉宾参加由深圳市卫生健康发展研究和数据管理中心主办的线上活动。" },
{ "year": "2007", "title": "持续开展融合实践", "description": "长期探索东方智慧与现代心理学在家庭教育和个人成长场景中的应用。" }
],
"achievements": [
"慧遇书院创始人",
"OPEN-MIND 数字生命与心智模式实验室联合发起人",
"持续开展家庭教育与心理成长实践",
"形成觉察、关系与行动相结合的课程路径"
],
"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课程采用主题讲解、案例讨论和日常练习相结合的方式帮助参与者识别熟悉的关系模式练习更清楚地表达需要也更有空间听见彼此。\n\n本课程为成长教育类演示产品不替代医疗诊断、心理治疗或危机干预。具体课时、讲师、价格和服务方式待后台补充。",
"image": "/images/mock/family-relationship-v1.webp",
"href": "/products/family-relationship",
"status": "published"
},
{
"slug": "human-wisdom",
"title": "人本智慧学",
"subtitle": "从理解自我走向稳定实践",
"type": "course",
"summary": "以觉察、理解与行动为主线,在长期练习中建立更稳定的自我关系和成长节奏。",
"content": "人本智慧学是一套面向日常成长的系统课程。它不追求短期的情绪高峰,而是帮助学习者在真实生活中理解自己、理解关系,并形成可以持续的实践。\n\n课程将结合主题学习、反思记录、同伴交流和阶段复盘让抽象知识逐步落到工作、家庭与个人选择中。\n\n当前页面为演示内容正式课程大纲、学习周期和适用人群以后台最终发布为准。",
"image": "/images/mock/inner-wisdom-v1.webp",
"href": "/products/human-wisdom",
"status": "published"
},
{
"slug": "awareness-door",
"title": "觉知之门",
"subtitle": "看见当下,是改变的开始",
"type": "course",
"summary": "通过结构化观察与记录,建立对情绪、身体感受和行为反应的基础觉察。",
"content": "觉知之门是成长路径的基础课程,帮助学习者放慢自动反应,学习观察情绪、身体感受、念头和行为之间的联系。\n\n课程包含短时练习、生活记录和主题交流重点不是评判自己而是更准确地看见正在发生什么。\n\n本页面为视觉和内容结构演示不构成效果保证。",
"image": "/images/mock/growth-workshop-v1.webp",
"href": "/products/awareness-door",
"status": "published"
},
{
"slug": "breakthrough-action",
"title": "破局之力",
"subtitle": "把新的理解转化为行动",
"type": "course",
"summary": "从一个可执行的小改变开始,练习在压力与惯性中做出更清晰的选择。",
"content": "破局之力关注从理解到行动的过程。课程将帮助学习者识别阻碍行动的惯性,拆解目标,并设计可以在现实生活中完成的小步练习。\n\n通过行动记录与阶段复盘参与者能够看到变化如何发生也能更诚实地面对困难与反复。\n\n当前为演示课程正式服务内容待运营确认。",
"image": "/images/mock/breakthrough-action-v2.webp",
"href": "/products/breakthrough-action",
"status": "published"
},
{
"slug": "clarity-path",
"title": "明心之道",
"subtitle": "在复杂生活里校准价值与方向",
"type": "course",
"summary": "围绕价值、选择与长期方向,整理真正重要的事,形成更稳定的内在坐标。",
"content": "明心之道面向已经具备一定自我观察基础的学习者,围绕价值选择、人生方向和长期实践展开。\n\n课程通过主题讨论、个人书写和同伴交流帮助参与者辨别外部期待与真实需要在复杂处境中保留清晰。\n\n当前页面为演示具体课程边界待正式确认。",
"image": "/images/mock/clarity-path-v2.webp",
"href": "/products/clarity-path",
"status": "published"
},
{
"slug": "companion-assistant",
"title": "智能陪伴助手",
"subtitle": "让日常觉察和练习更容易坚持",
"type": "tool",
"summary": "通过温和提醒、练习记录与阶段回顾,为课程学习和日常成长提供数字化支持。",
"content": "智能陪伴助手是慧遇书院规划中的数字化成长工具,用于承接课程后的日常练习、记录与回顾。\n\n一期官网展示重点包括产品愿景、核心场景和未来入口不开放医疗建议、自动诊断或危机干预功能。\n\n该产品当前为概念演示正式功能、隐私规则和开放时间以后续发布为准。",
"image": "/images/mock/companion-assistant-v1.webp",
"href": "/products/companion-assistant",
"status": "published"
}
],
"articles": [
{
"slug": "official-channel-guide",
"type": "statement",
"title": "慧遇书院官方渠道与信息识别说明(演示)",
"summary": "说明官网、官方账号、课程信息和对外联络的统一识别原则。",
"content": "慧遇书院官网是品牌信息、卢慧老师资料、课程产品与官方声明的统一展示入口。\n\n正式上线后所有官方账号、课程链接和联系方式将通过本页面集中公示。未在官网列出的账号、群聊、收款方式或课程承诺请先通过官方渠道核验。\n\n本声明当前为演示内容公司主体和账号信息完成确认后更新。",
"cover": "/images/hero-bg-v3.webp",
"publishedAt": "2026-08-11",
"status": "published",
"statementNo": "HUIYU-DEMO-20260811-01",
"sourceName": "",
"sourceUrl": ""
},
{
"slug": "content-verification-notice",
"type": "statement",
"title": "人物资料与内容核验说明(演示)",
"summary": "说明人物经历、资质、理论名称和素材授权的上线核验边界。",
"content": "当前官网人物内容依据百度百科离线资料整理,用于页面结构和视觉效果确认。\n\n学历、项目性质、专业资质、理论名称以及图片版权等信息正式上线前必须由本人或公司负责人提供证明材料并逐项确认。未完成核验的字段不会作为正式宣传依据。",
"cover": "/images/人物/卢慧-百科肖像.jpg",
"publishedAt": "2026-08-11",
"status": "published",
"statementNo": "HUIYU-DEMO-20260811-02",
"sourceName": "",
"sourceUrl": ""
},
{
"slug": "course-service-boundary",
"type": "statement",
"title": "课程与成长服务边界说明(演示)",
"summary": "课程和智能工具不替代医疗诊断、心理治疗与危机干预。",
"content": "官网所展示的课程、内容和数字工具定位于成长教育与日常练习支持。\n\n它们不提供疾病诊断不替代精神科、心理治疗或其他医疗服务也不适用于紧急危机处理。如出现自伤、伤人风险或严重身心不适请及时联系当地急救、医疗机构或专业危机干预资源。",
"cover": "/images/mock/inner-wisdom-v1.webp",
"publishedAt": "2026-08-10",
"status": "published",
"statementNo": "HUIYU-DEMO-20260810-01",
"sourceName": "",
"sourceUrl": ""
},
{
"slug": "website-content-preview",
"type": "news",
"title": "慧遇书院官网一期内容预览上线",
"summary": "品牌、人物、课程、声明和团队内容已形成完整的官网演示闭环。",
"content": "慧遇书院官网一期演示内容已经上线。首页采用沉浸式长滚动设计,集中呈现品牌理念、卢慧老师、课程产品、官方信息、陪伴团队与品牌动态。\n\n后台同步支持结构化内容编辑、素材管理、草稿发布、版本恢复和网站设置。当前所有示例信息将继续由运营人员逐项核验和替换。",
"cover": "/images/hero-bg-v3.webp",
"publishedAt": "2026-08-11",
"status": "published",
"statementNo": "",
"sourceName": "慧遇书院官网",
"sourceUrl": ""
},
{
"slug": "growth-course-map",
"type": "news",
"title": "从觉察到行动:成长课程路径演示",
"summary": "以觉知之门、破局之力和明心之道构成循序渐进的学习路径。",
"content": "为了让课程之间的关系更清楚,官网演示版将成长路径整理为三个阶段:先看见正在发生什么,再练习新的行动,最后回到价值与长期方向。\n\n不同阶段并非简单的等级关系。学习者可以结合自己的现实问题从最需要的位置开始并在实践中持续复盘。",
"cover": "/images/mock/growth-workshop-v1.webp",
"publishedAt": "2026-08-08",
"status": "published",
"statementNo": "",
"sourceName": "慧遇书院内容团队(演示)",
"sourceUrl": ""
},
{
"slug": "digital-companion-concept",
"type": "news",
"title": "智能陪伴助手概念页完成首轮设计",
"summary": "围绕练习提醒、成长记录和阶段回顾,探索克制可信的数字化陪伴。",
"content": "智能陪伴助手概念页完成首轮设计。产品将优先服务于日常练习与课程后的持续支持,避免制造依赖,也不会以算法替代专业判断。\n\n后续设计将重点补充隐私规则、使用边界、人工支持入口和无障碍体验。",
"cover": "/images/mock/companion-assistant-v1.webp",
"publishedAt": "2026-08-05",
"status": "published",
"statementNo": "",
"sourceName": "慧遇书院产品团队(演示)",
"sourceUrl": ""
},
{
"slug": "season-forum-2022",
"type": "media",
"title": "卢慧参与第十二期“四季论坛”",
"summary": "百度百科资料记载,卢慧于 2022 年作为嘉宾参与相关线上活动。",
"content": "据百度百科收录资料2022 年 9 月 28 日,由深圳市卫生健康发展研究和数据管理中心主办的第十二期“四季论坛”通过网络直播举行,卢慧作为嘉宾参与活动。\n\n本条用于演示媒体与外部活动信息的展示结构正式上线前将补充权威来源链接并再次核验活动信息。",
"cover": "/images/mock/growth-workshop-v1.webp",
"publishedAt": "2022-09-28",
"status": "published",
"statementNo": "",
"sourceName": "深圳市卫生健康发展研究相关公开信息(待补链接)",
"sourceUrl": ""
}
],
"team": [
{ "slug": "course-support-team", "name": "课程支持团队", "role": "学习路径与课程服务", "summary": "负责课程介绍、学习提醒与阶段沟通,让每位学习者清楚了解服务内容和边界。", "avatar": "/images/mock/growth-workshop-v1.webp", "specialties": ["课程咨询", "学习支持", "服务说明"], "status": "published" },
{ "slug": "content-research-team", "name": "内容研究团队", "role": "知识整理与内容核验", "summary": "把专业内容整理成可信、易理解的表达,并持续维护来源、版本和核验状态。", "avatar": "/images/mock/inner-wisdom-v1.webp", "specialties": ["内容策划", "知识整理", "事实核验"], "status": "published" },
{ "slug": "product-technology-team", "name": "产品技术团队", "role": "数字工具与官网体验", "summary": "负责官网、后台与智能陪伴工具的产品设计和稳定运行。", "avatar": "/images/mock/companion-assistant-v1.webp", "specialties": ["产品设计", "技术研发", "隐私安全"], "status": "published" },
{ "slug": "community-operation-team", "name": "社群运营团队", "role": "活动组织与日常连接", "summary": "围绕公开活动、主题分享和社群秩序提供温和、清晰的运营支持。", "avatar": "/images/mock/family-relationship-v1.webp", "specialties": ["活动运营", "社群支持", "用户沟通"], "status": "published" }
],
"channels": [
{ "name": "微信公众号", "account": "慧遇书院(演示)", "url": "", "qrCode": "" },
{ "name": "视频号", "account": "卢慧老师(演示)", "url": "", "qrCode": "" },
{ "name": "小红书", "account": "官方账号待确认", "url": "", "qrCode": "" },
{ "name": "抖音", "account": "官方账号待确认", "url": "", "qrCode": "" }
],
"contact": {
"phone": "400-000-0000演示",
"email": "service@example.com演示",
"address": "中国·上海(详细地址待后台配置)"
},
"seo": {
"title": "慧遇书院|家庭教育 心理成长与专业陪伴",
"description": "慧遇书院官方网站,呈现卢慧老师、家庭教育与心理成长课程、智能陪伴工具、官方声明和品牌动态。"
}
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

71
design-qa.md Normal file
View File

@@ -0,0 +1,71 @@
# 慧遇书院官网 P0-P2 界面优化验收
- source visual truth path: `artifacts/ui-audit-2026-08-12/01-home-desktop.png``02-about-desktop.png``03-teacher-desktop.png``04-home-mobile.png``05-about-mobile.png``06-admin-desktop.png`
- implementation screenshot path: `artifacts/ui-audit-2026-08-12/09-home-desktop-final.png``13-teacher-desktop-final.png``14-home-mobile-final.png``12-mobile-menu-final.png`
- desktop viewport: 1440 × 1000 CSS px截图同尺寸device density 由浏览器默认值保持一致
- mobile viewport: 390 × 844 CSS px截图同尺寸device density 由浏览器默认值保持一致
- state: 首页首屏、人物区进入视口、移动端首屏、移动端导航展开、后台登录
## Full-view comparison evidence
- 桌面首屏保持原有山水与莲花主视觉、左侧标题和顶部导航构图;品牌显示统一为“慧遇书院 / HUIYU ACADEMY”。
- 移动端标题收敛到 55.38px,顶部栏 70px页面宽度无溢出首屏信息仍完整呈现。
- 首页移动端总高度从审查时约 12159px 降至 10901px课程与区块密度得到收敛。
## Focused region comparison evidence
- 人物区:标题统一为 58px正文宽度扩大渐显结束时卡片透明度 0.98,文字在进入视口过程中保持可读。
- 导航:桌面链接点击高度 44px移动菜单链接高度 48px展开后锁定页面滚动关闭按钮和焦点轮廓清晰。
- 关于区:指标保持三列对齐,数字动画缩短至 620ms卡片边界和阴影可辨识。
- 后台登录:页面标题与 Logo 均读取已发布品牌配置,显示“慧遇书院”。
## Required fidelity surfaces
- Fonts and typography: H2 统一为最高 58px人物标题不再单独放大中文标题和副标题字距收紧小字对比度提高。
- Spacing and layout rhythm: 桌面区块使用 104-120px 流式间距,移动端统一为 68px卡片内部间距和移动端图片高度同步压缩。
- Colors and visual tokens: 保留浅蓝、暖白、深蓝体系;统一卡片阴影和边框,正文灰蓝提高对比度。
- Image quality and asset fidelity: 保留真实品牌图形、卢慧肖像及既有生成素材;没有使用占位图、手绘 SVG 或 CSS 假素材。
- Copy and content: 代码、Mock 内容、导航、CTA、文章标题与后台标题中的“慧愈科技”已清零品牌动态读取后台配置。
- Accessibility and interaction: 焦点轮廓强化,桌面/移动点击目标达到 44px/48px保留 reduced-motion390px 无横向溢出。
## Comparison history
1. Earlier P0: 新旧品牌名称混用;人物正文在动画过程中接近透明。
- Fix: 清理旧品牌硬编码并让导航/后台标题读取品牌配置;渐显初始透明度提高、动画缩短,人物遮罩动画缩短至 720ms。
- Post-fix evidence: 页面正文不包含“慧愈科技”,后台标题为“后台管理登录|慧遇书院”,人物区最终截图文字清晰。
2. Earlier P1: 标题层级跨度大、区块留白过多、导航点击目标小、移动页过长。
- Fix: 统一 H2、正文、英文眉题字号收敛桌面和移动区块间距导航点击高度扩展移动课程卡和图片压缩。
- Post-fix evidence: 桌面 H2 主要区块均为 58px桌面导航 44px移动标题 55.38px、顶部栏 70px、总高 10901px。
3. Earlier P2: 阴影边框不统一、按钮反馈简单、辅助文字偏淡。
- Fix: 新增统一 card shadow token、按钮光泽反馈、无触摸 hover 回退、辅助文字对比度提升。
- Post-fix evidence: 桌面与移动浏览器截图中卡片层级稳定,按钮与链接交互状态一致。
## Verification
- `npm test`: 6 tests passed
- `npm run typecheck`: passed
- `npm run test:smoke`: 21 sitemap routes、health、headers、robots、static cache passed
- Docker: container healthy
- Browser console errors/warnings: 0
- Desktop and mobile horizontal overflow: 0
## Findings
- 无剩余可执行 P0/P1/P2 问题。
- 后台完整登录后业务页仍建议在拿到正式管理员凭据后做一次真实发布/恢复流程复核;不影响本轮视觉优化验收。
## 首屏高清背景专项验收2026-08-12
- 原因:旧首屏默认图只有 1672×941且前端放大至 1.07 倍;在 2× DPR 屏幕上会发生明显插值模糊。
- 修复:重制并输出 3344×1882 高清母版,提供 AVIF约 91 KB与 WebP约 173 KB两种生产格式默认缩放降至 1.035,保留轻量视差效果。
- 运行时证据:首页实际加载 `hero-bg-v3.avif`,自然尺寸 3344×18821280×720、DPR 2 环境下无横向溢出。
- 视觉检查:山体、水面、莲花和日光边缘清晰,标题与导航对比度正常,首屏构图未偏离既定设计方向。
## 陪伴团队窄屏专项验收2026-08-12
- 原因:原两列卡片在 7601100px 区间仍保持双列,每张卡片同时保留 210px 图片,文字列宽被压缩至单字宽度。
- 修复1100px 以下改为单列卡片520px 以下改为图片在上、文字在下,并为文字容器增加最小宽度保护和中文标题禁用逐字断行。
- 运行时证据820×900 下卡片宽 658px、文字列宽 370px标题与角色均保持单行无横向溢出。
- 视觉检查:窄屏卡片图片、标题、角色和简介层级清晰,卡片之间留白一致。
final result: passed

View File

@@ -0,0 +1,119 @@
# 慧愈科技官网部署与恢复手册
## 1. 生产部署前准备
1. 准备 2 核 2 GB、40—80 GB SSD 的 Linux 服务器,并配置 1—2 GB Swap。
2. 安装 Docker Engine 与 Docker Compose Plugin。
3. 首次部署从 Git 仓库拉取代码,并固定到明确版本标签;生产服务器只在发布窗口构建,不执行日常开发构建。
4. 根据 `.env.docker.example` 创建 `.env.docker`,替换管理员密码和不少于 32 字符的会话密钥。
5. 设置 `SITE_URL=https://正式域名``NUXT_SESSION_COOKIE_SECURE=true`
6.`BACKUP_HOST_DIR` 指向另一块磁盘或已挂载的异机目录,不能与 Docker 数据卷共存于同一故障域。
推荐目录与首次拉取方式:
```bash
sudo mkdir -p /srv/huiyu
sudo chown "$USER":"$USER" /srv/huiyu
git clone https://git.huiyushuyuan.cn/GuangYu/guanwang.git /srv/huiyu
cd /srv/huiyu
cp .env.docker.example .env.docker
chmod 600 .env.docker
```
编辑 `.env.docker` 后再启动。该文件包含生产密钥,已被 `.gitignore` 排除,禁止提交到仓库。
## 2. 启动与验收
```bash
docker compose build --pull
docker compose up -d
docker compose ps
curl -fsS http://127.0.0.1:3000/api/health
docker compose logs --tail=100 web
```
容器必须显示 `healthy`。随后通过 Nginx 配置域名、HTTPS、请求体大小限制与访问日志。
## 3. 数据目录
- SQLiteDocker 数据卷中的 `/app/data/db/huiyu.sqlite`
- 原图:`/app/data/uploads/original`
- WebP 与缩略图:`/app/data/uploads/derived`
- 回收站:`/app/data/uploads/trash`
- 加密备份:宿主机 `BACKUP_HOST_DIR`
不要对数据库文件直接复制热备,也不要运行 `docker compose down -v`
## 4. 备份计划
设置独立保存的 `BACKUP_ENCRYPTION_PASSWORD`,建议通过服务器密钥管理或 root-only 环境文件注入。
```bash
BACKUP_ENCRYPTION_PASSWORD='从密钥管理读取' sh scripts/backup.sh daily
BACKUP_ENCRYPTION_PASSWORD='从密钥管理读取' sh scripts/backup.sh weekly
BACKUP_ENCRYPTION_PASSWORD='从密钥管理读取' sh scripts/backup.sh monthly
```
脚本会使用 SQLite 在线备份,打包数据库和全部素材,使用 AES-256-CBC + PBKDF2 加密,并在落盘后立即解密列目录校验。保留策略为每日 7 天、每周 28 天、每月 186 天。
建议 Cron
```cron
15 3 * * * cd /srv/huiyu && /usr/local/bin/run-huiyu-backup daily
25 3 * * 0 cd /srv/huiyu && /usr/local/bin/run-huiyu-backup weekly
35 3 1 * * cd /srv/huiyu && /usr/local/bin/run-huiyu-backup monthly
```
`run-huiyu-backup` 应从安全位置读取加密密码,不把密码直接写进 crontab。
## 5. 恢复流程
恢复会先自动生成一份当前数据备份,然后停止应用、校验备份数据库、覆盖数据库和素材并重新启动。
```bash
CONFIRM_RESTORE=yes \
BACKUP_ENCRYPTION_PASSWORD='从密钥管理读取' \
sh scripts/restore.sh /绝对路径/huiyu-daily-YYYYMMDD-HHMMSS-PID.tar.gz.enc
```
恢复后检查:
```bash
docker compose ps
curl -fsS http://127.0.0.1:3000/api/health
docker compose logs --tail=100 web
```
再检查首页、后台登录、最近发布内容和至少一张上传图片。每月选择最近备份在测试服务器演练一次。
## 6. 素材回收站清理
后台删除只移动到回收站,默认 30 天后才允许物理清理:
```bash
MEDIA_TRASH_RETENTION_DAYS=30 sh scripts/purge-media-trash.sh
```
被草稿或已发布页面引用的素材不能移入回收站。
## 7. 回滚应用
生产镜像必须使用不可变版本号,例如 `huiyu-official-site:2026.08.10-1`。应用代码回滚时保留数据卷不变;如果版本包含不兼容迁移,先按第 4 节备份,再按对应版本的迁移说明操作。
## 8. 从 Git 仓库发布更新
发布前先创建备份,再以明确提交或版本标签更新;不要在生产服务器直接编辑代码。
```bash
cd /srv/huiyu
BACKUP_ENCRYPTION_PASSWORD='从密钥管理读取' sh scripts/backup.sh daily
git fetch --prune --tags origin
git checkout main
git pull --ff-only origin main
docker compose build --pull
docker compose up -d
docker compose ps
curl -fsS http://127.0.0.1:3000/api/health
```
若健康检查或页面验收失败,先保留现场日志,再切回上一版本标签并重新构建;不要删除 `huiyu_official_site_data` 数据卷。

View File

@@ -0,0 +1,344 @@
# 慧愈科技官网|一期架构决策与精简开发计划 V1.2
> 决策日期2026-08-10
> 决策目标:按“主要供访客浏览、低并发、少运营人员、单服务器”的真实场景,采用简单、稳定、可恢复的实现,不为二期需求提前堆复杂架构。
## 1. 已直接确定的架构
### 1.1 应用形态
采用**一个 NuxtVue全栈应用**
```text
Nuxt 应用
├── 官网前台SSR / 混合渲染
├── 管理后台:/admin
├── 服务端 API/api
├── SQLite
└── 本地素材目录
```
理由:
- Vue 生态成熟,设计图也适合组件化实现;
- 官网需要 SEO不采用纯 Vue SPA改用基于 Vue 的 Nuxt 服务端渲染;
- 前台、后台、API 使用同一套 TypeScript减少服务数量和交接成本
- 一期不再单独引入 FastAPI避免同时维护 Node 和 Python 两套运行环境。
### 1.2 技术基线
```text
语言TypeScript
前台与后台Vue + Nuxt
样式CSS Variables + SCSS建立品牌设计令牌
数据校验:统一 Schema 校验
数据库SQLite
素材:服务器本地磁盘
入口与 HTTPSNginx
进程与部署Docker Compose使用预构建镜像
测试:单元测试 + API 集成测试 + 核心流程端到端测试
```
不固定未经验证的具体小版本;实现时使用当时仍在维护的稳定版本并锁定依赖。
## 2. 服务器决策
一期接受:
```text
CPU2 核
内存2 GB
系统盘:建议至少 40—80 GB SSD
Swap1—2 GB仅作为突发保护不作为正常内存使用
实例数1
```
使用限制:
- 不在生产服务器上执行重型前端构建;在本地或 CI 构建后部署制品/镜像;
- Nuxt 只启动一个应用实例,避免多个进程同时写 SQLite
- Nginx、Nuxt 和定时备份是主要常驻服务;
- 设置 CPU、内存、磁盘和错误率告警
- 磁盘使用达到 70% 时预警,达到 80% 前必须扩容或清理。
## 3. SQLite 决策
一期采用 SQLite符合低写入、单应用服务器的官网场景。
必须遵守:
- 数据库文件与应用代码分离,固定挂载到 `/data/db/`
- 不把 SQLite 放到 NFS、SMB 等网络文件系统;
- 开启 WAL、外键约束和合理的 `busy_timeout`
- 写事务保持短小,不在事务中执行图片处理或外部网络请求;
- 数据库迁移必须有版本号;
- 发布前自动备份,迁移失败停止发布;
- 只能有一个负责写入的应用实例。
暂不使用 PostgreSQL。出现以下任一条件时再迁移
- 需要多台应用服务器;
- 多名运营人员频繁同时写入并出现锁等待;
- 日志持续出现数据库锁超时;
- 需要复杂报表、数据分析或外部系统直接访问数据库;
- 单机已经无法满足可用性要求。
数据访问层不得散落手写 SQL集中在 repository/service 层,便于二期迁移 PostgreSQL。
## 4. 本地素材决策
一期图片、PDF 和少量视频直接保存在服务器本地:
```text
/data/uploads/original/
/data/uploads/derived/
/data/uploads/trash/
```
必须实现:
- 文件名随机化,原始文件名只保存为元数据;
- 文件头、MIME、扩展名、大小和图片像素校验
- 生成 WebP/缩略图,保留原图;
- 保存宽高、ALT、来源、版权和授权状态
- 素材引用保护;
- 删除先进入回收站,延迟物理删除;
- Nginx 直接提供静态资源并设置缓存头;
- 上传目录不允许执行脚本。
暂不使用对象存储和 CDN。出现以下情况再迁移
- 素材量达到约 20 GB 或磁盘增长明显加快;
- 访客跨地域访问图片明显缓慢;
- 需要多服务器部署;
- 图片流量成为服务器主要带宽开销;
- 需要更强的素材版本、权限或防盗链能力。
数据库只保存相对路径和素材 ID不把域名写死确保将来迁移对象存储时不用改业务数据。
## 5. 备份是一期 P0不延后
本地存储足够使用,但**本地存储不等于备份**。服务器磁盘损坏、误删除、系统重装或云主机被释放时,数据库和图片可能同时消失。
一期最低备份策略:
- 每天使用 SQLite 在线备份生成一致性数据库副本;
- 每天增量备份上传素材;
- 数据库备份和素材备份必须复制到另一台机器或低成本云存储;
- 保留 7 个每日备份、4 个每周备份、6 个每月备份;
- 备份文件加密;
- 每月执行一次自动校验;
- 上线前和重大迁移前执行一次完整备份;
- 至少完成一次从空服务器恢复数据库和素材的演练。
目标:
```text
RPO最多丢失 24 小时数据
RTO故障后 4 小时内恢复官网
```
## 6. 一期功能范围
### 6.1 官网前台
- 沉浸式长滚动首页;
- 关于慧愈;
- 卢慧老师人物页;
- 课程与产品列表、详情;
- 品牌动态、官方声明列表和详情;
- 陪伴团队;
- 官方渠道与联系方式;
- 隐私政策、服务条款、版权和备案信息;
- Sitemap、Canonical、Open Graph、robots 和基础 JSON-LD
- 响应式布局、无障碍基础和减少动态效果支持。
### 6.2 管理后台
- 一个超级管理员账号;
- 登录、退出、修改密码和登录限流;
- 首页固定模块的内容、排序和显隐;
- 卢慧老师、公司品牌、课程产品、文章声明、团队和官方渠道维护;
- 素材上传、选择、替换、引用保护和回收站;
- 草稿、预览、发布、下线;
- 最近 20 个关键内容版本,恢复版本时生成草稿;
- 基础操作日志;
- 网站和 SEO 基础配置。
### 6.3 一期明确不做
- 多级角色和复杂权限矩阵;
- 编辑提交、审核员审批的多人审核流;
- 定时发布;
- Redis、消息队列和后台任务集群
- PostgreSQL
- 对象存储和 CDN
- 后台全局搜索;
- 复杂访问统计;
- AI 标准信息管理和内容监测;
- 字段级版本差异对比;
- 多语言、支付、CRM、学习系统和用户注册。
## 7. 一期仍然不能省的生产要求
- HTTPS
- 密码安全哈希;
- HttpOnly/Secure/SameSite Cookie
- 登录限流和统一失败提示;
- 后端权限校验;
- XSS、CSRF、上传和路径穿越防护
- 数据库迁移与迁移前备份;
- 异机备份和恢复演练;
- 健康检查、结构化日志和错误告警;
- 部署制品有版本号并可回滚;
- 密钥不进入代码仓库;
- 图片和人物资料上线前完成版权、肖像和事实核验。
## 8. 精简开发计划
### 阶段 0内容与页面冻结2—3 个工作日)
- 确定页面、锚点和详情路由;
- 确定后台需要支持的内容字段和校验规则;
- 用明确标注的演示数据跑通人物、课程、渠道和法律信息页面;
- 建立素材授权状态字段和上线检查规则;
- 域名、DNS、服务器权限和异机备份目标不阻塞开发在部署阶段提供。
### 阶段 1工程和部署骨架4—6 个工作日)
- 初始化 Nuxt 全栈工程;
- 建立模块边界、设计令牌和代码规范;
- 接入 SQLite、迁移、日志和测试
- 完成 Docker Compose、Nginx、HTTPS、健康检查和备份脚本
- 建立测试环境。
### 阶段 2后台数据与登录7—10 个工作日)
- 超级管理员登录和安全策略;
- 公司、品牌、人物、课程、文章、团队、渠道和首页模块数据模型;
- 素材上传和衍生图;
- 草稿、发布快照、下线、版本恢复和操作日志。
### 阶段 3管理后台页面8—12 个工作日)
- 首页管理;
- 人物和品牌资料;
- 课程、文章、声明、团队和渠道;
- 素材中心;
- 预览、发布和版本历史;
- 网站与 SEO 配置。
### 阶段 4官网前台与动效10—15 个工作日)
- 按设计图实现长滚动首页;
- 完成人物、课程、文章、声明和团队详情;
- 完成视差、渐显、遮罩、数字动画及移动端降级;
- 完成 SEO、结构化数据和响应式图片。
### 阶段 5联调和生产验收7—10 个工作日)
- API、权限、发布、上传和版本测试
- 浏览器、移动端、横向滚动和视觉对比检查;
- XSS、CSRF、登录限流和上传安全测试
- 性能检查;
- 备份恢复和应用回滚演练;
- 内容、链接、二维码、备案和法律信息验收;
- 编写部署、运营和故障恢复手册。
## 9. 工期判断
精简后粗略工作量:**38—56 人日**,不包含备案等待、大量文案创作和重新拍摄图片。
- 1 名全栈开发:约 9—12 周;
- 2 名开发合理分工:约 6—8 周;
- 如果真实内容、素材和授权不能及时提供,开发完成也不能直接上线。
## 10. 我方可以直接决策的事项
以下事项无需反复确认,按本文件执行:
- Vue/Nuxt + TypeScript
- 单体全栈应用;
- SQLite + WAL
- 本地素材 + 相对路径;
- 单服务器、单应用实例;
- Docker Compose + Nginx
- 前台 SSR后台客户端交互
- 固定首页模块,不做低代码;
- 一期单超级管理员,不做复杂审核;
- 草稿、发布快照和最近 20 个历史版本;
- 基础 SEO、日志、安全、备份和回滚属于一期
- 代码模块化、接口校验、测试和中文交接文档。
## 11. 只需用户提供的外部信息
### 11.1 开发阶段不需要用户提供
以下信息不阻塞开发,统一到部署阶段再提供:
- 域名及备案现状;
- DNS 管理权限;
- 服务器登录权限;
- 生产服务器系统与最终磁盘容量;
- 异机备份目标和备份账号;
- HTTPS 证书相关信息。
### 11.2 必须做成后台可配置的内容
以下内容不能写死在代码或环境变量中,全部通过后台维护:
- 官网名称、品牌名称、Logo、favicon 和品牌简介;
- 公司主体、统一社会信用代码、电话、邮箱和地址;
- ICP 备案号、公安备案号、页脚版权;
- 卢慧老师介绍、经历、成果、标签、照片和外部权威链接;
- 课程与产品;
- 团队成员;
- 官方渠道、账号、链接和二维码;
- 品牌动态、官方声明和媒体报道;
- 隐私政策、服务条款和版权说明;
- 首页标题、文案、图片、按钮、模块顺序和显隐;
- 默认 SEO、页面 SEO、Open Graph 图片和结构化信息所需字段;
- 素材来源、版权状态、授权状态和 ALT 文本。
开发和测试阶段使用演示数据。演示数据必须带有明显的“演示/待替换”标记,生产发布前由后台替换并通过上线检查。
### 11.3 不能放进普通后台配置的敏感信息
以下信息只允许通过服务器环境变量、只读配置文件或密钥管理方式注入:
- 数据库和会话密钥;
- 管理员初始密码;
- 备份账号和密钥;
- 邮件、短信或第三方服务密钥;
- DNS、服务器和部署平台凭证
- 错误监控和统计平台的服务端密钥。
后台可以显示这些能力是否已配置,但不得读取或回显完整密钥。
### 11.4 生产上线前检查
真实内容可以在开发完成后由运营人员通过后台录入,但生产上线前必须完成:
- 演示数据清零;
- 公司、人物、课程和渠道信息审核;
- 图片版权与肖像授权确认;
- 电话、邮箱、链接和二维码逐项点击验证;
- 备案与法律文本确认;
- 最终内容审核人签字确认。
## 12. 后台网站配置字段范围
后台“网站配置”按职责拆分,避免一个页面堆放所有信息:
```text
网站配置
├── 品牌与站点
├── 公司主体与备案
├── 联系方式
├── 页脚与法律页面
├── 默认 SEO
├── 首页模块
└── 功能开关
```
配置修改同样保留草稿和发布快照。已发布配置由前台读取,编辑中的草稿不会直接影响线上官网。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,433 @@
# 慧愈科技官网|需求审查与开发计划 V1.1
> 审查对象:`慧愈科技官网后台功能需求设计_V1.0.md`
> 审查日期2026-08-10
> 目标:在不改变现有设计方向的前提下,补齐可开发、可上线、可回滚、可交接所需的产品与工程约束。
> 架构更新一期已按单服务器、Vue/Nuxt、SQLite 和本地素材方案精简,实际开发以 `一期架构决策与精简开发计划_V1.2.md` 为准;本文保留完整风险审查记录。
## 1. 审查结论
V1.0 的产品方向、信息架构和前后台视觉目标基本正确,可以作为需求底稿;但目前**不建议直接进入完整功能开发**。
主要原因不是缺少页面,而是以下生产级闭环尚未定义完整:
- 草稿、审核稿和线上版本如何隔离;
- 谁可以审核、是否允许审核自己的内容;
- 一页式首页与文章/课程详情页的 URL 体系;
- 素材如何存储、生成衍生图、校验引用和回收;
- 预览、定时发布、缓存刷新和失败重试如何实现;
- 数据库、对象存储、部署、备份、回滚和监控如何验收。
这些问题需要在开发前完成补充,否则后期会集中返工数据库、权限和发布流程。
## 2. 做得正确、可以保留的部分
1. 官网定位明确:官方信息源,而不是复杂交易或课程学习平台。
2. 后台定位正确:固定模块的专用 CMS不做通用低代码搭建器。
3. 核心数据强调单一来源,适合长期维护和 SEO。
4. 前台组件和后台预览组件复用,方向正确。
5. 草稿、审核、发布、版本和操作日志意识完整。
6. 素材引用保护、富文本安全和后端权限校验已经被列为要求。
7. 一期明确排除了支付、CRM、多租户、学习系统等高复杂度能力。
8. 前台动效克制、文字保留在 HTML、SSR/SSG 友好等要求合理。
## 3. 开工前必须修正的问题
### P0-1项目边界和前台路由没有闭合
文档名称以“官网后台”为主,但内容实际包含前台官网、后台 CMS、API、素材服务、SEO 和发布系统。必须明确一期交付物是一个完整系统,而不只是后台页面。
建议一期前台路由:
```text
/
/luhui
/products
/products/:slug
/news
/news/:slug
/statements
/statements/:slug
/media
/team
/privacy
/terms
```
首页锚点可继续使用 `/#about``/#luhui` 等,但文章、课程和声明必须拥有独立、稳定、可分享、可收录的 URL。
### P0-2发布状态模型不完整
目前的 `status` 字段无法同时表达编辑状态、审核状态和线上状态,也没有定时发布、发布失败、下线原因等信息。
建议拆分:
```text
workflow_status: draft | in_review | rejected | approved
publication_status: unpublished | scheduled | published | offline | failed
published_revision_id
scheduled_at
published_at
offline_at
reviewer_id
review_comment
```
线上页面始终读取 `published_revision_id`,编辑草稿不能直接污染线上内容。
### P0-3审核权限需要职责分离
三种角色还不足以形成安全规则,至少补充:
- 内容管理员不能审核或发布自己提交的敏感内容;
- 审核员只能审核被授权模块;
- 发布、下线、恢复版本、修改权限属于独立权限点;
- 超级管理员操作同样记录日志;
- 首个超级管理员通过部署初始化,不开放前台注册。
必须形成“角色 × 模块 × 动作”的权限矩阵,并由 API 强制校验。
### P0-4版本管理被错误地放在 P1
文档前面要求所有关键内容保留历史版本,后面却把版本管理列为 P1。版本数据结构会影响所有核心表不能后补。
调整建议:
- 版本快照、发布版本指针、恢复为草稿P0
- 可视化字段级差异对比P1。
### P0-5实时预览需要定义安全边界
预览不能直接访问未发布 API也不能复用管理员长期登录凭证。
建议:
- 前台与后台使用同一套展示组件;
- 后台生成短时、一次性或可撤销的预览令牌;
- 预览页面强制 `noindex`、禁止缓存并校验访问范围;
- PC、平板、手机预览是视口切换不复制三套页面。
### P0-6素材模型和存储方案不完整
`Media.url``reference_count` 不足以支持生产使用。需要补充:
- 原文件与 WebP/AVIF/缩略图等衍生文件;
- 对象存储 key不把完整 URL 当作唯一标识;
- MIME 嗅探、扩展名白名单、文件头校验、大小与像素限制;
- SHA-256 去重;
- 图片宽高、焦点、版权来源、授权状态和 ALT
- `media_reference` 关联表,引用次数由关系计算;
- 上传失败重试、孤儿文件清理和删除回收站;
- 对象存储和 CDN 的备份及缓存刷新策略。
### P0-7登录错误提示存在账号枚举风险
“账号不存在”和“密码错误”分别提示会泄露账号是否存在。生产环境应对外统一提示“账号、密码或验证码错误”,详细原因仅写入安全日志。
同时补充:
- 密码使用 Argon2id 或 bcrypt
- 管理端采用安全 Cookie 会话或明确的 Token 存储策略;
- Cookie 设置 `HttpOnly``Secure``SameSite`
- 登录、验证码和找回密码接口限流;
- 连续失败采用指数退避或短时锁定;
- 管理员密码重置令牌短时有效且仅能使用一次;
- 二期可加入 TOTP 双因素认证。
### P0-8部署、备份、回滚和监控缺失
文档只描述了业务功能,没有生产交付闭环。上线前必须定义:
- 开发、测试、预发布、生产环境隔离;
- 数据库迁移、上线前备份和迁移回滚方案;
- PostgreSQL 定时备份与恢复演练;
- 对象存储版本或跨区域备份策略;
- 前端、API、后台的健康检查
- 结构化日志、错误告警、接口耗时和发布失败告警;
- 部署版本号、制品留存和一键回滚上一稳定版本;
- 密钥只通过环境变量或密钥服务注入,不进入代码仓库。
## 4. 建议在一期同时补齐的问题
### P1-1SEO 需要稳定 URL、slug 与重定向
- Product、Article、TeamMember 增加唯一 `slug`
- slug 修改时自动保留 301 重定向;
- 预览和后台页面禁止索引;
- Sitemap、Canonical、Open Graph、robots 和基础 JSON-LD 应属于上线 P0而不是上线后再补
- `Keywords` 可以保留兼容字段,但不应作为核心 SEO 工作量。
### P1-2文章扩展字段没有进入数据模型
官方声明的编号、附件、盖章文件,以及媒体报道的媒体名称、原文 URL、媒体 Logo 等没有体现在 `Article` 模型中。
建议采用统一文章主表 + 类型扩展字段,或明确的 JSON schema不能只写在页面需求中。
### P1-3首页模块缺少正式数据模型
至少需要:
```text
Page
PageSection
PageRevision
Publication
Redirect
```
`PageSection` 保存模块类型、排序、显隐和结构化配置。模块配置必须按类型校验,不能存储不受约束的任意 JSON。
### P1-4定时发布需要幂等和失败处理
必须明确:
- 同一版本重复触发不会重复发布;
- 发布任务具有唯一业务键;
- 失败后按策略重试;
- 超过阈值进入人工处理;
- 服务器统一保存 UTC后台按 Asia/Shanghai 展示;
- 发布成功后再刷新页面缓存和 CDN。
### P1-5富文本需要内容规范
建议选择受控块编辑器,而不是允许任意 HTML。定义允许的标题层级、图片、视频、表格、引用、按钮和 FAQ 块;服务端二次清洗,外链默认补充安全属性。
### P1-6法律和隐私页面缺失
至少加入隐私政策、服务条款、版权说明和备案信息页面。如果“联系我们”要收集姓名、电话或微信,需要补充用户同意、数据保存期限、查看权限和删除机制。
### P1-7前台性能指标需要可验收
将“首屏重点优化”改成明确目标:
- LCP ≤ 2.5s
- INP ≤ 200ms
- CLS ≤ 0.1
- 首屏主图按设备输出合适尺寸;
- 动效支持 `prefers-reduced-motion`
- 常见桌面和移动浏览器无横向滚动。
### P1-8操作日志需要防止记录敏感数据
日志中的修改前后内容应过滤密码、Token、验证码和隐私字段日志应追加写、限制修改、定义保存期限并提供按操作人、模块、时间和对象检索。
## 5. 一期范围重排
### 一期上线必须完成P0
- 前台官网:首页、人物、课程/产品、文章/声明、团队、官方渠道和法律页面;
- 后台登录、账号、RBAC 权限矩阵;
- 公司、品牌、卢慧老师、课程产品、内容、团队和官方渠道管理;
- 首页固定模块管理;
- 素材上传、衍生图、引用保护和授权字段;
- 草稿、提交审核、退回、审批、发布、下线;
- 核心实体版本快照和恢复为草稿;
- 安全预览;
- 基础 SEO、Sitemap、Canonical、robots、JSON-LD
- 操作日志;
- 数据库迁移、备份恢复、监控告警和部署回滚。
### 一期可简化
- 仪表盘只保留待办、最近更新和快捷入口;
- 不做复杂 PV/UV 报表,接入成熟统计平台;
- 版本对比先显示两个版本的完整内容,字段级差异后补;
- 素材标签和筛选做基础版;
- 首页模块只支持固定模块排序、显隐和内容编辑;
- 后台只保证桌面端 1366px 及以上,平板可浏览但不作为主要编辑设备。
### 延后到二期P1/P2
- 后台全局搜索;
- 复杂访问分析;
- AI 内容质量检查和外部信息监测;
- 双因素认证;
- 字段级版本差异;
- 批量复杂排版和更多自动化运营能力。
## 6. 推荐技术基线
在团队没有既定技术栈的前提下,建议采用较稳健、易交接的组合:
```text
前台官网Nuxt + TypeScriptSSR/混合渲染)
管理后台Vue + TypeScript
后端 APIFastAPI
数据库PostgreSQL
对象存储S3 兼容存储 + CDN
异步任务:按定时发布和图片处理量选择 PostgreSQL 任务表或 Redis 队列
反向代理Nginx 或云平台托管入口
```
选择理由:前台可服务 SEO后台与前台共用 Vue 生态FastAPI 接口边界清晰PostgreSQL 适合版本快照与结构化内容,对象存储适合图片和附件。
建议采用单仓库分应用组织:
```text
apps/
├── web/ # 前台官网
├── admin/ # 管理后台
└── api/ # 后端 API
packages/
├── ui/ # 品牌基础组件与设计令牌
├── contracts/ # API 契约和共享类型
└── config/ # 共享工程配置
docs/
infra/
```
不要强行让前台与后台复用所有页面组件;应复用设计令牌、内容 schema 和前台预览组件,避免把后台表单逻辑耦合进前台。
## 7. 分阶段开发计划
以下按“2 名全职开发 + 兼职设计/测试/内容负责人”估算。实际排期需要在技术栈、部署环境和内容量确认后锁定。
### 阶段 0需求冻结与内容核验3—5 个工作日)
交付物:
- 一期页面与路由清单;
- 权限矩阵;
- 状态机与发布流程图;
- 字段字典和内容 schema
- 真实公司、人物、课程、渠道与法律信息清单;
- 域名、服务器、对象存储、CDN 和备案决策记录。
开工闸门P0 问题全部有明确结论。
### 阶段 1工程与部署基础5—7 个工作日)
任务:
- 建立 monorepo、代码规范、提交规范和环境配置
- 建立前台、后台、API 和数据库项目;
- 配置迁移、种子数据、CI 构建和测试;
- 建立开发、测试、预发布环境;
- 接入结构化日志、健康检查和基础错误监控;
- 完成自动备份与恢复脚本初版。
验收:全新环境可按文档一键启动,迁移可升级和回退,密钥不进入仓库。
### 阶段 2认证、权限与发布内核8—12 个工作日)
任务:
- 管理员、角色、权限矩阵和登录限流;
- 草稿、审核、退回、批准、发布、下线状态机;
- Revision、Publication、审计日志
- 短时预览令牌;
- 定时发布任务、幂等、重试和失败告警。
验收:内容管理员不能绕过审核发布;审核员不能越权;恢复版本只生成草稿;重复发布不会产生错误状态。
### 阶段 3素材中心与核心内容模型10—15 个工作日)
任务:
- 素材上传、校验、对象存储、缩略图/WebP/AVIF
- 素材授权、ALT、标签和引用关系
- 公司、品牌、官方渠道;
- 卢慧老师、经历、成果、课程关联与权威链接;
- 课程产品、文章、声明、媒体报道和团队成员;
- 首页 PageSection 固定模块配置。
验收:全部核心对象具备创建、编辑、审核、版本、发布、下线和引用保护。
### 阶段 4管理后台10—15 个工作日)
任务:
- 登录、仪表盘;
- 首页三栏编辑与响应式预览;
- 人物、公司品牌、课程、内容、团队和渠道管理;
- 素材选择器;
- 审核中心、历史版本、日志与网站设置;
- 表单校验、自动保存、未保存离开提醒和错误恢复。
验收:运营人员不改代码即可完成一次“编辑—审核—预览—发布—下线—恢复”闭环。
### 阶段 5官网前台与动效10—15 个工作日)
任务:
- 建立品牌设计令牌和响应式布局;
- 完成沉浸式首页各模块;
- 完成人物、课程、内容、声明、团队和法律详情页;
- 完成导航锚点、滚动章节激活和浏览器返回;
- 完成视差、淡入、遮罩揭示、数字递增和降级动效;
- 实现图片响应式加载、懒加载和无障碍结构。
验收:与设计参考图同视口对比后通过视觉 QA桌面、平板、手机无横向滚动主要交互可用。
### 阶段 6SEO、发布联动与内容迁移6—9 个工作日)
任务:
- Sitemap、robots、Canonical、OG 和 JSON-LD
- slug 和 301 重定向;
- 发布后缓存/CDN 刷新;
- 预览 noindex 与禁止缓存;
- 导入已核验的人物、品牌、课程、团队和官方渠道内容;
- 检查所有图片 ALT、链接、二维码和联系方式。
验收:搜索引擎可直接读取正文,结构化数据通过验证,未发布内容不可被公开访问。
### 阶段 7系统测试与安全验收8—12 个工作日)
任务:
- API 单元测试、权限测试和发布状态机测试;
- 前后台核心流程端到端测试;
- XSS、CSRF、越权、上传、限流和会话测试
- 性能、Core Web Vitals、浏览器兼容和无障碍检查
- 备份恢复、迁移回滚和应用版本回滚演练;
- 运营使用手册、部署手册和故障处理手册。
验收P0 用例全部通过,无高危安全问题,恢复演练成功。
### 阶段 8生产发布与观察3—5 个工作日)
任务:
- 上线前数据库和素材备份;
- 灰度或维护窗口发布;
- 域名、HTTPS、CDN、备案和搜索引擎配置
- 发布后冒烟测试;
- 观察错误率、接口耗时、任务队列和页面性能;
- 准备并验证回滚路径。
验收:连续观察期内无阻断问题,监控和告警有效,运营账号与权限完成交接。
## 8. 工期与人员判断
粗略工作量:**60—90 人日**,不含大量内容撰写、原始图片重制、备案等待和外部账号申请。
- 2 名有经验的全职开发,设计/测试/内容兼职配合:约 9—12 周;
- 1 名全栈开发独立完成:约 14—18 周;
- 如果一期继续保留后台全局搜索、复杂统计、字段级版本对比等能力,工期需要继续增加。
人员职责建议:
| 角色 | 主要职责 |
| --- | --- |
| 产品/内容负责人 | 需求决策、事实核验、文案、素材授权、验收签字 |
| 前端开发 | 官网、后台、动效、响应式、可访问性和视觉 QA |
| 后端开发 | 数据模型、权限、发布、素材、SEO、日志和部署接口 |
| 测试/交付 | 测试用例、安全回归、部署与恢复演练、交接文档 |
## 9. 阶段 0 需要确认的决策
1. 一期是否同时交付前台官网和后台 CMS建议是。
2. 课程和品牌动态是否需要独立详情页;建议:是。
3. “联系我们”只展示官方渠道,还是收集访客信息;建议一期只展示渠道,避免引入隐私数据管理。
4. 是否要求内容管理员与审核员职责分离;建议:是。
5. 前台部署采用自有服务器还是云平台;需要结合预算和运维能力确认。
6. 图片、视频和 PDF 使用哪家对象存储与 CDN。
7. 是否已有域名、ICP备案主体、公安备案、隐私政策和服务条款。
8. 技术栈是否接受 Vue/Nuxt + FastAPI + PostgreSQL。
9. 一期真实内容由谁提供、谁核验、谁最终签字。
## 10. 开发启动建议
先用 3—5 个工作日完成阶段 0不立即搭大量页面。阶段 0 通过后,再并行启动“工程基础”“品牌设计令牌”和“内容核验”。这能把数据库、权限、发布和素材这些高返工风险提前消化掉。

42
nuxt.config.ts Normal file
View File

@@ -0,0 +1,42 @@
export default defineNuxtConfig({
compatibilityDate: '2026-08-10',
devtools: { enabled: false },
css: ['~/assets/css/main.scss'],
app: {
head: {
htmlAttrs: { lang: 'zh-CN' },
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'theme-color', content: '#f5f7fa' },
],
link: [{ rel: 'icon', type: 'image/png', href: '/images/brand-logo-v2.png' }],
},
pageTransition: { name: 'page', mode: 'out-in' },
},
runtimeConfig: {
sessionPassword: '',
sessionCookieSecure: false,
databasePath: './data/db/huiyu.sqlite',
initialAdminUsername: 'admin',
initialAdminPassword: '',
public: {
siteUrl: 'http://localhost:3000',
},
},
nitro: {
compressPublicAssets: true,
externals: {
external: ['better-sqlite3'],
},
},
routeRules: {
'/images/**': { headers: { 'Cache-Control': 'public, max-age=31536000, immutable' } },
'/admin/**': { ssr: false, headers: { 'X-Robots-Tag': 'noindex, nofollow', 'Cache-Control': 'no-store' } },
'/api/admin/**': { headers: { 'Cache-Control': 'no-store' } },
'/api/auth/**': { headers: { 'Cache-Control': 'no-store' } },
},
typescript: {
strict: true,
typeCheck: false,
},
})

10801
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "huiyu-official-site",
"private": true,
"type": "module",
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"preview": "nuxt preview",
"typecheck": "nuxt typecheck",
"test": "vitest run",
"test:smoke": "node scripts/smoke-test.mjs"
},
"dependencies": {
"@phosphor-icons/vue": "^2.2.1",
"bcryptjs": "^3.0.2",
"better-sqlite3": "^13.0.3",
"nuxt": "^4.5.2",
"sharp": "^0.35.3",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"sass": "^1.93.3",
"typescript": "^5.9.3",
"vitest": "^3.2.4",
"vue-tsc": "^3.1.1"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

47
scripts/backup.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/bin/sh
set -eu
backup_kind="${1:-daily}"
case "$backup_kind" in daily|weekly|monthly) ;; *) echo "用法: BACKUP_ENCRYPTION_PASSWORD=... sh scripts/backup.sh [daily|weekly|monthly]" >&2; exit 2 ;; esac
backup_password="${BACKUP_ENCRYPTION_PASSWORD:-}"
if [ "${#backup_password}" -lt 20 ]; then
echo "BACKUP_ENCRYPTION_PASSWORD 至少需要 20 个字符" >&2
exit 2
fi
timestamp="$(date +%Y%m%d-%H%M%S)-$$"
archive_name="huiyu-${backup_kind}-${timestamp}.tar.gz.enc"
work_dir="/app/data/backups/work-${timestamp}"
backup_dir="${BACKUP_HOST_DIR:-./backups}"
case "$backup_dir" in ''|'/'|'.'|'..') echo "BACKUP_HOST_DIR 不能是空目录、根目录或当前目录" >&2; exit 2 ;; esac
docker compose exec -T \
-e BACKUP_WORK_DIR="$work_dir" \
-e BACKUP_ARCHIVE_NAME="$archive_name" \
-e BACKUP_ENCRYPTION_PASSWORD="$BACKUP_ENCRYPTION_PASSWORD" \
web sh -eu -c '
mkdir -p "$BACKUP_WORK_DIR" /app/offsite-backups
node -e "
const Database = require(\"better-sqlite3\");
const db = new Database(\"/app/data/db/huiyu.sqlite\");
db.backup(process.env.BACKUP_WORK_DIR + \"/huiyu.sqlite\")
.then(() => db.close())
.catch(error => { console.error(error); db.close(); process.exit(1); });
"
cp -R /app/data/uploads "$BACKUP_WORK_DIR/uploads"
tar -czf "$BACKUP_WORK_DIR/bundle.tar.gz" -C "$BACKUP_WORK_DIR" huiyu.sqlite uploads
openssl enc -aes-256-cbc -salt -pbkdf2 -iter 200000 \
-pass env:BACKUP_ENCRYPTION_PASSWORD \
-in "$BACKUP_WORK_DIR/bundle.tar.gz" \
-out "/app/offsite-backups/$BACKUP_ARCHIVE_NAME"
openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
-pass env:BACKUP_ENCRYPTION_PASSWORD \
-in "/app/offsite-backups/$BACKUP_ARCHIVE_NAME" | tar -tzf - >/dev/null
rm -rf "$BACKUP_WORK_DIR"
'
find "$backup_dir" -type f -name 'huiyu-daily-*.tar.gz.enc' -mtime +7 -delete
find "$backup_dir" -type f -name 'huiyu-weekly-*.tar.gz.enc' -mtime +28 -delete
find "$backup_dir" -type f -name 'huiyu-monthly-*.tar.gz.enc' -mtime +186 -delete
echo "备份完成并校验: $backup_dir/$archive_name"

28
scripts/purge-media-trash.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/bin/sh
set -eu
retention_days="${MEDIA_TRASH_RETENTION_DAYS:-30}"
case "$retention_days" in *[!0-9]*|'') echo "MEDIA_TRASH_RETENTION_DAYS 必须是非负整数" >&2; exit 2 ;; esac
docker compose exec -T -e RETENTION_DAYS="$retention_days" web node - <<'NODE'
const Database = require('better-sqlite3')
const fs = require('node:fs')
const path = require('node:path')
const db = new Database('/app/data/db/huiyu.sqlite')
const cutoff = new Date(Date.now() - Number(process.env.RETENTION_DAYS) * 86400000).toISOString()
const rows = db.prepare("SELECT id, asset_key, original_path, derived_path, thumbnail_path FROM media_assets WHERE status = 'trash' AND deleted_at <= ?").all(cutoff)
const root = path.resolve('/app/data/uploads/trash')
const remove = relative => {
const target = path.resolve(root, relative)
if (!target.startsWith(root + path.sep)) throw new Error('unsafe media path')
try { fs.unlinkSync(target) } catch (error) { if (error.code !== 'ENOENT') throw error }
}
const finish = db.transaction(row => {
for (const relative of [row.original_path, row.derived_path, row.thumbnail_path]) remove(relative)
db.prepare("UPDATE media_assets SET status = 'deleted', updated_at = ? WHERE id = ?").run(new Date().toISOString(), row.id)
db.prepare("INSERT INTO audit_logs (action, entity_type, entity_key, metadata_json, created_at) VALUES ('media.purge', 'media', ?, '{}', ?)").run(row.asset_key, new Date().toISOString())
})
for (const row of rows) finish(row)
db.close()
console.log(`已物理清理 ${rows.length} 个回收站素材`)
NODE

42
scripts/restore.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/sh
set -eu
archive="${1:-}"
if [ -z "$archive" ] || [ ! -f "$archive" ]; then echo "用法: CONFIRM_RESTORE=yes BACKUP_ENCRYPTION_PASSWORD=... sh scripts/restore.sh /绝对路径/备份.enc" >&2; exit 2; fi
if [ "${CONFIRM_RESTORE:-}" != "yes" ]; then echo "恢复会覆盖当前数据库和上传素材,请设置 CONFIRM_RESTORE=yes" >&2; exit 2; fi
backup_password="${BACKUP_ENCRYPTION_PASSWORD:-}"
if [ "${#backup_password}" -lt 20 ]; then echo "BACKUP_ENCRYPTION_PASSWORD 至少需要 20 个字符" >&2; exit 2; fi
archive_dir="$(cd "$(dirname "$archive")" && pwd)"
archive_name="$(basename "$archive")"
restore_id="$(date +%Y%m%d-%H%M%S)"
echo "停止应用并保留恢复前备份…"
BACKUP_ENCRYPTION_PASSWORD="$BACKUP_ENCRYPTION_PASSWORD" sh scripts/backup.sh daily
docker compose stop web
cleanup() { docker compose start web >/dev/null 2>&1 || true; }
trap cleanup EXIT INT TERM
docker compose run --rm --no-deps \
-v "$archive_dir:/restore-source:ro" \
-e RESTORE_ARCHIVE="/restore-source/$archive_name" \
-e RESTORE_ID="$restore_id" \
-e BACKUP_ENCRYPTION_PASSWORD="$BACKUP_ENCRYPTION_PASSWORD" \
web sh -eu -c '
restore_dir="/app/data/restore-$RESTORE_ID"
mkdir -p "$restore_dir"
openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
-pass env:BACKUP_ENCRYPTION_PASSWORD -in "$RESTORE_ARCHIVE" | tar -xzf - -C "$restore_dir"
test -f "$restore_dir/huiyu.sqlite"
node -e "const Database=require(\"better-sqlite3\"); const db=new Database(process.argv[1],{readonly:true}); const result=db.pragma(\"integrity_check\",{simple:true}); db.close(); if(result!==\"ok\") process.exit(1)" "$restore_dir/huiyu.sqlite"
rm -f /app/data/db/huiyu.sqlite /app/data/db/huiyu.sqlite-wal /app/data/db/huiyu.sqlite-shm
cp "$restore_dir/huiyu.sqlite" /app/data/db/huiyu.sqlite
rm -rf /app/data/uploads
cp -R "$restore_dir/uploads" /app/data/uploads
rm -rf "$restore_dir"
'
docker compose start web
trap - EXIT INT TERM
echo "恢复完成。请执行: docker compose ps && curl -fsS http://localhost:3000/api/health"

View File

@@ -0,0 +1,31 @@
import { readFile } from 'node:fs/promises'
const baseUrl = (process.env.SITE_URL || 'http://localhost:3000').replace(/\/$/, '')
const username = process.env.ADMIN_USERNAME || 'admin'
const password = process.env.ADMIN_PASSWORD
if (!password) throw new Error('请通过 ADMIN_PASSWORD 环境变量提供后台密码')
const loginResponse = await fetch(`${baseUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!loginResponse.ok) throw new Error(`后台登录失败: ${loginResponse.status}`)
const cookie = loginResponse.headers.get('set-cookie')?.split(';')[0]
if (!cookie) throw new Error('后台登录未返回会话 Cookie')
const headers = { Cookie: cookie }
const currentResponse = await fetch(`${baseUrl}/api/admin/site`, { headers })
if (!currentResponse.ok) throw new Error(`读取后台草稿失败: ${currentResponse.status}`)
const current = (await currentResponse.json()).data
const mock = JSON.parse(await readFile(new URL('../content/演示数据/一期官网演示数据.json', import.meta.url), 'utf8'))
const document = { ...current, ...mock, hero: { ...current.hero, titleLine1: '身心疗愈', titleLine2: '科技赋能', subtitle: '用科技与专业,陪伴每一次心灵的成长', cta: '了解慧遇书院' } }
const saveResponse = await fetch(`${baseUrl}/api/admin/site`, {
method: 'PUT', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify(document),
})
if (!saveResponse.ok) throw new Error(`保存演示草稿失败: ${saveResponse.status} ${await saveResponse.text()}`)
const publishResponse = await fetch(`${baseUrl}/api/admin/site/publish`, { method: 'POST', headers })
if (!publishResponse.ok) throw new Error(`发布演示数据失败: ${publishResponse.status} ${await publishResponse.text()}`)
const result = await publishResponse.json()
console.log(`演示数据已写入后台并发布为 V${result.version}`)

36
scripts/smoke-test.mjs Normal file
View File

@@ -0,0 +1,36 @@
import assert from 'node:assert/strict'
const baseUrl = (process.env.BASE_URL || 'http://localhost:3000').replace(/\/$/, '')
async function get(path) {
const response = await fetch(`${baseUrl}${path}`, { redirect: 'manual' })
assert.equal(response.status, 200, `${path} returned ${response.status}`)
return response
}
const health = await get('/api/health')
const healthBody = await health.json()
assert.equal(healthBody.status, 'ok')
assert.equal(healthBody.checks.database, 'ok')
assert.equal(healthBody.checks.uploads, 'writable')
const homepage = await get('/')
assert.equal(homepage.headers.get('x-content-type-options'), 'nosniff')
assert.equal(homepage.headers.get('x-frame-options'), 'DENY')
assert.match(homepage.headers.get('content-security-policy') || '', /frame-ancestors 'none'/)
const homepageHtml = await homepage.text()
assert.match(homepageHtml, /application\/ld\+json/)
const sitemap = await get('/sitemap.xml')
const sitemapXml = await sitemap.text()
const urls = [...sitemapXml.matchAll(/<loc>(.*?)<\/loc>/g)].map(match => new URL(match[1]).pathname)
assert.ok(urls.length >= 5, 'sitemap should contain public routes')
for (const path of urls) await get(path)
const robots = await get('/robots.txt')
assert.match(await robots.text(), /Disallow: \/admin/)
const hero = await get('/images/hero-bg-v3.webp')
assert.match(hero.headers.get('cache-control') || '', /immutable/)
console.log(`Smoke test passed: ${urls.length} sitemap routes, health, headers, robots and static cache.`)

View File

@@ -0,0 +1,13 @@
import { requireAdmin } from '../../utils/auth'
import { useDatabase } from '../../utils/db'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const rows = useDatabase().prepare(`
SELECT audit_logs.id, audit_logs.action, audit_logs.entity_type, audit_logs.entity_key,
audit_logs.ip_address, audit_logs.created_at, admin_users.display_name
FROM audit_logs LEFT JOIN admin_users ON admin_users.id = audit_logs.operator_id
ORDER BY audit_logs.id DESC LIMIT 50
`).all() as Array<Record<string, string | number | null>>
return { data: rows.map(row => ({ id: row.id, action: row.action, entityType: row.entity_type, entityKey: row.entity_key, ipAddress: row.ip_address, createdAt: row.created_at, operator: row.display_name || '系统' })) }
})

View File

@@ -0,0 +1,81 @@
import { randomUUID } from 'node:crypto'
import { mkdir, rm, writeFile } from 'node:fs/promises'
import { extname } from 'node:path'
import sharp from 'sharp'
import { z } from 'zod'
import { mediaUrl, safeUploadPath, serializeMedia, type MediaAssetRow } from '../../utils/media'
import { useDatabase } from '../../utils/db'
const allowedTypes = {
'image/jpeg': { extension: '.jpg', inputExtensions: ['.jpg', '.jpeg'] },
'image/png': { extension: '.png', inputExtensions: ['.png'] },
'image/webp': { extension: '.webp', inputExtensions: ['.webp'] },
} as const
const metadataSchema = z.object({
altText: z.string().trim().max(160).default(''),
source: z.string().trim().max(200).default(''),
copyright: z.string().trim().max(200).default(''),
authorizationStatus: z.enum(['pending', 'authorized', 'restricted']).default('pending'),
})
function detectImageType(data: Buffer) {
if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return 'image/jpeg'
if (data.length >= 8 && data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'
if (data.length >= 12 && data.toString('ascii', 0, 4) === 'RIFF' && data.toString('ascii', 8, 12) === 'WEBP') return 'image/webp'
}
export default defineEventHandler(async (event) => {
const user = await requireAdmin(event)
const parts = await readMultipartFormData(event)
const file = parts?.find(part => part.name === 'file' && part.filename)
if (!file?.data?.length) throw createError({ statusCode: 400, statusMessage: '请选择图片文件' })
if (file.data.length > 8 * 1024 * 1024) throw createError({ statusCode: 413, statusMessage: '单张图片不能超过 8MB' })
const detectedType = detectImageType(file.data)
if (!detectedType || !(detectedType in allowedTypes)) throw createError({ statusCode: 415, statusMessage: '仅支持 JPG、PNG、WebP 图片' })
const typeConfig = allowedTypes[detectedType as keyof typeof allowedTypes]
const inputExtension = extname(file.filename || '').toLowerCase()
if (!typeConfig.inputExtensions.includes(inputExtension as never)) throw createError({ statusCode: 415, statusMessage: '文件扩展名与实际图片类型不一致' })
let imageMetadata
try { imageMetadata = await sharp(file.data, { limitInputPixels: 40_000_000 }).metadata() }
catch { throw createError({ statusCode: 415, statusMessage: '图片内容损坏或无法解析' }) }
const width = imageMetadata.width || 0
const height = imageMetadata.height || 0
if (!width || !height || width > 10_000 || height > 10_000 || width * height > 40_000_000) {
throw createError({ statusCode: 413, statusMessage: '图片尺寸不能超过 10000×10000 或 4000 万像素' })
}
const fields = Object.fromEntries((parts || []).filter(part => !part.filename && part.name).map(part => [part.name!, part.data.toString('utf8')]))
const metadata = metadataSchema.parse(fields)
const now = new Date()
const dateDir = `${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}`
const assetKey = randomUUID()
const originalPath = `original/${dateDir}/${assetKey}${typeConfig.extension}`
const derivedPath = `derived/${dateDir}/${assetKey}-large.webp`
const thumbnailPath = `derived/${dateDir}/${assetKey}-thumb.webp`
const writtenPaths = [originalPath, derivedPath, thumbnailPath]
await Promise.all(writtenPaths.map(path => mkdir(safeUploadPath(path).replace(/\/[^/]+$/, ''), { recursive: true })))
try {
await writeFile(safeUploadPath(originalPath), file.data, { flag: 'wx' })
await sharp(file.data).rotate().resize({ width: 2400, height: 2400, fit: 'inside', withoutEnlargement: true }).webp({ quality: 84 }).toFile(safeUploadPath(derivedPath))
await sharp(file.data).rotate().resize({ width: 480, height: 480, fit: 'inside', withoutEnlargement: true }).webp({ quality: 78 }).toFile(safeUploadPath(thumbnailPath))
const timestamp = now.toISOString()
const db = useDatabase()
const row = db.transaction(() => {
const result = db.prepare(`
INSERT INTO media_assets (asset_key, original_name, mime_type, extension, bytes, width, height, original_path, derived_path, thumbnail_path, alt_text, source_text, copyright_text, authorization_status, status, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
`).run(assetKey, file.filename, detectedType, typeConfig.extension, file.data.length, width, height, originalPath, derivedPath, thumbnailPath, metadata.altText, metadata.source, metadata.copyright, metadata.authorizationStatus, user.id, timestamp, timestamp)
writeAuditLog(event, { operatorId: user.id, action: 'media.upload', entityType: 'media', entityKey: assetKey, metadata: { originalName: file.filename, bytes: file.data.length, width, height, derivedUrl: mediaUrl(derivedPath) } })
return db.prepare('SELECT * FROM media_assets WHERE id = ?').get(result.lastInsertRowid) as MediaAssetRow
})()
return { url: mediaUrl(derivedPath), media: serializeMedia(row) }
} catch (error) {
await Promise.all(writtenPaths.map(path => rm(safeUploadPath(path), { force: true })))
throw error
}
})

View File

@@ -0,0 +1,17 @@
import { isMediaReferenced, moveMediaFiles, serializeMedia, type MediaAssetRow } from '../../../utils/media'
import { useDatabase } from '../../../utils/db'
export default defineEventHandler(async (event) => {
const user = await requireAdmin(event)
const id = Number(getRouterParam(event, 'id'))
if (!Number.isSafeInteger(id) || id < 1) throw createError({ statusCode: 400, statusMessage: '无效的素材编号' })
const db = useDatabase()
const row = db.prepare("SELECT * FROM media_assets WHERE id = ? AND status = 'active'").get(id) as MediaAssetRow | undefined
if (!row) throw createError({ statusCode: 404, statusMessage: '素材不存在' })
if (isMediaReferenced(db, row)) throw createError({ statusCode: 409, statusMessage: '素材正在被草稿或已发布页面引用,不能删除' })
await moveMediaFiles(row, 'trash')
const timestamp = new Date().toISOString()
db.prepare("UPDATE media_assets SET status = 'trash', deleted_at = ?, updated_at = ? WHERE id = ?").run(timestamp, timestamp, id)
writeAuditLog(event, { operatorId: user.id, action: 'media.trash', entityType: 'media', entityKey: row.asset_key })
return { data: serializeMedia({ ...row, status: 'trash', deleted_at: timestamp, updated_at: timestamp }) }
})

View File

@@ -0,0 +1,25 @@
import { z } from 'zod'
import { serializeMedia, type MediaAssetRow } from '../../../utils/media'
import { useDatabase } from '../../../utils/db'
const updateSchema = z.object({
altText: z.string().trim().max(160),
source: z.string().trim().max(200),
copyright: z.string().trim().max(200),
authorizationStatus: z.enum(['pending', 'authorized', 'restricted']),
})
export default defineEventHandler(async (event) => {
const user = await requireAdmin(event)
const id = Number(getRouterParam(event, 'id'))
if (!Number.isSafeInteger(id) || id < 1) throw createError({ statusCode: 400, statusMessage: '无效的素材编号' })
const input = updateSchema.parse(await readBody(event))
const db = useDatabase()
const timestamp = new Date().toISOString()
const result = db.prepare(`UPDATE media_assets SET alt_text = ?, source_text = ?, copyright_text = ?, authorization_status = ?, updated_at = ? WHERE id = ? AND status = 'active'`)
.run(input.altText, input.source, input.copyright, input.authorizationStatus, timestamp, id)
if (!result.changes) throw createError({ statusCode: 404, statusMessage: '素材不存在' })
const row = db.prepare('SELECT * FROM media_assets WHERE id = ?').get(id) as MediaAssetRow
writeAuditLog(event, { operatorId: user.id, action: 'media.update', entityType: 'media', entityKey: row.asset_key })
return { data: serializeMedia(row) }
})

View File

@@ -0,0 +1,16 @@
import { moveMediaFiles, serializeMedia, type MediaAssetRow } from '../../../../utils/media'
import { useDatabase } from '../../../../utils/db'
export default defineEventHandler(async (event) => {
const user = await requireAdmin(event)
const id = Number(getRouterParam(event, 'id'))
if (!Number.isSafeInteger(id) || id < 1) throw createError({ statusCode: 400, statusMessage: '无效的素材编号' })
const db = useDatabase()
const row = db.prepare("SELECT * FROM media_assets WHERE id = ? AND status = 'trash'").get(id) as MediaAssetRow | undefined
if (!row) throw createError({ statusCode: 404, statusMessage: '回收站中不存在该素材' })
await moveMediaFiles(row, 'restore')
const timestamp = new Date().toISOString()
db.prepare("UPDATE media_assets SET status = 'active', deleted_at = NULL, updated_at = ? WHERE id = ?").run(timestamp, id)
writeAuditLog(event, { operatorId: user.id, action: 'media.restore', entityType: 'media', entityKey: row.asset_key })
return { data: serializeMedia({ ...row, status: 'active', deleted_at: null, updated_at: timestamp }) }
})

View File

@@ -0,0 +1,11 @@
import { isMediaReferenced, serializeMedia, type MediaAssetRow } from '../../../utils/media'
import { useDatabase } from '../../../utils/db'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const query = getQuery(event)
const status = query.status === 'trash' ? 'trash' : 'active'
const db = useDatabase()
const rows = db.prepare('SELECT * FROM media_assets WHERE status = ? ORDER BY created_at DESC LIMIT 200').all(status) as MediaAssetRow[]
return { data: rows.map(row => serializeMedia(row, isMediaReferenced(db, row))) }
})

View File

@@ -0,0 +1,7 @@
import { requireAdmin } from '../../../utils/auth'
import { readSiteDocument } from '../../../utils/db'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
return readSiteDocument('draft')
})

View File

@@ -0,0 +1,19 @@
import { requireAdmin } from '../../../utils/auth'
import { writeAuditLog } from '../../../utils/audit'
import { useDatabase } from '../../../utils/db'
import { siteDocumentSchema } from '../../../utils/site-schema'
export default defineEventHandler(async (event) => {
const user = await requireAdmin(event)
const parsed = siteDocumentSchema.safeParse(await readBody(event))
if (!parsed.success) {
throw createError({ statusCode: 422, statusMessage: '内容校验失败', data: { issues: parsed.error.issues } })
}
const document = parsed.data
const timestamp = new Date().toISOString()
const db = useDatabase()
db.prepare('UPDATE site_documents SET draft_json = ?, updated_by = ?, updated_at = ? WHERE document_key = ?')
.run(JSON.stringify(document), user.id, timestamp, 'website')
writeAuditLog(event, { operatorId: user.id, action: 'save_draft', entityType: 'site_document', entityKey: 'website' })
return { data: document, updatedAt: timestamp }
})

View File

@@ -0,0 +1,35 @@
import { requireAdmin } from '../../../utils/auth'
import { writeAuditLog } from '../../../utils/audit'
import { useDatabase } from '../../../utils/db'
export default defineEventHandler(async (event) => {
const user = await requireAdmin(event)
const db = useDatabase()
const timestamp = new Date().toISOString()
const publish = db.transaction(() => {
const row = db.prepare('SELECT draft_json FROM site_documents WHERE document_key = ?').get('website') as { draft_json: string }
const versionRow = db.prepare(`
SELECT COALESCE(MAX(version), 0) + 1 AS version FROM revisions
WHERE entity_type = ? AND entity_key = ?
`).get('site_document', 'website') as { version: number }
db.prepare(`
INSERT INTO revisions (entity_type, entity_key, version, snapshot_json, operator_id, action, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run('site_document', 'website', versionRow.version, row.draft_json, user.id, 'publish', timestamp)
db.prepare(`
UPDATE site_documents SET published_json = draft_json, updated_by = ?, updated_at = ?, published_at = ?
WHERE document_key = ?
`).run(user.id, timestamp, timestamp, 'website')
db.prepare(`
DELETE FROM revisions WHERE entity_type = ? AND entity_key = ? AND id NOT IN (
SELECT id FROM revisions WHERE entity_type = ? AND entity_key = ? ORDER BY version DESC LIMIT 20
)
`).run('site_document', 'website', 'site_document', 'website')
return versionRow.version
})
const version = publish()
writeAuditLog(event, { operatorId: user.id, action: 'publish', entityType: 'site_document', entityKey: 'website', metadata: { version } })
return { success: true, publishedAt: timestamp, version }
})

View File

@@ -0,0 +1,11 @@
import { useDatabase } from '../../../utils/db'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const rows = useDatabase().prepare(`
SELECT version, action, comment, created_at AS createdAt
FROM revisions WHERE entity_type = 'site_document' AND entity_key = 'website'
ORDER BY version DESC LIMIT 20
`).all() as Array<{ version: number; action: string; comment: string | null; createdAt: string }>
return { data: rows }
})

View File

@@ -0,0 +1,16 @@
import { useDatabase } from '../../../../../utils/db'
import { siteDocumentSchema } from '../../../../../utils/site-schema'
export default defineEventHandler(async (event) => {
const user = await requireAdmin(event)
const version = Number(getRouterParam(event, 'version'))
if (!Number.isSafeInteger(version) || version < 1) throw createError({ statusCode: 400, statusMessage: '无效的版本号' })
const db = useDatabase()
const revision = db.prepare(`SELECT snapshot_json FROM revisions WHERE entity_type = 'site_document' AND entity_key = 'website' AND version = ?`).get(version) as { snapshot_json: string } | undefined
if (!revision) throw createError({ statusCode: 404, statusMessage: '历史版本不存在' })
const snapshot = siteDocumentSchema.parse(JSON.parse(revision.snapshot_json))
const timestamp = new Date().toISOString()
db.prepare('UPDATE site_documents SET draft_json = ?, updated_by = ?, updated_at = ? WHERE document_key = ?').run(JSON.stringify(snapshot), user.id, timestamp, 'website')
writeAuditLog(event, { operatorId: user.id, action: 'revision.restore_to_draft', entityType: 'site_document', entityKey: 'website', metadata: { version } })
return { data: snapshot, restoredVersion: version, updatedAt: timestamp }
})

View File

@@ -0,0 +1,51 @@
import { compareSync } from 'bcryptjs'
import { z } from 'zod'
import { getAdminSession } from '../../utils/auth'
import { useDatabase } from '../../utils/db'
import { writeAuditLog } from '../../utils/audit'
const loginSchema = z.object({
username: z.string().trim().min(1).max(80),
password: z.string().min(8).max(200),
})
export default defineEventHandler(async (event) => {
const body = loginSchema.parse(await readBody(event))
const db = useDatabase()
const user = db.prepare(`
SELECT id, username, password_hash, display_name, status, failed_login_count, locked_until
FROM admin_users WHERE username = ?
`).get(body.username) as {
id: number
username: string
password_hash: string
display_name: string
status: string
failed_login_count: number
locked_until: string | null
} | undefined
const locked = user?.locked_until && new Date(user.locked_until) > new Date()
const valid = user && user.status === 'active' && !locked && compareSync(body.password, user.password_hash)
if (!valid) {
if (user && user.status === 'active') {
const failures = user.failed_login_count + 1
const lockedUntil = failures >= 5 ? new Date(Date.now() + 10 * 60_000).toISOString() : null
db.prepare('UPDATE admin_users SET failed_login_count = ?, locked_until = ?, updated_at = ? WHERE id = ?')
.run(failures, lockedUntil, new Date().toISOString(), user.id)
}
writeAuditLog(event, { operatorId: user?.id, action: 'login_failed', entityType: 'admin_user', entityKey: body.username })
throw createError({ statusCode: 401, statusMessage: '账号、密码或验证码错误' })
}
const timestamp = new Date().toISOString()
db.prepare('UPDATE admin_users SET failed_login_count = 0, locked_until = NULL, last_login_at = ?, updated_at = ? WHERE id = ?')
.run(timestamp, timestamp, user.id)
const session = await getAdminSession(event)
await session.update({ user: { id: user.id, username: user.username, displayName: user.display_name } })
writeAuditLog(event, { operatorId: user.id, action: 'login_success', entityType: 'admin_user', entityKey: String(user.id) })
return { user: session.data.user }
})

View File

@@ -0,0 +1,7 @@
import { getAdminSession } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const session = await getAdminSession(event)
await session.clear()
return { success: true }
})

View File

@@ -0,0 +1,3 @@
import { requireAdmin } from '../../utils/auth'
export default defineEventHandler(async (event) => ({ user: await requireAdmin(event) }))

View File

@@ -0,0 +1,20 @@
import { compareSync, hashSync } from 'bcryptjs'
import { z } from 'zod'
import { requireAdmin } from '../../utils/auth'
import { useDatabase } from '../../utils/db'
const passwordSchema = z.object({
currentPassword: z.string().min(8).max(200),
newPassword: z.string().min(12).max(200).regex(/[a-z]/, '新密码需包含小写字母').regex(/[A-Z]/, '新密码需包含大写字母').regex(/\d/, '新密码需包含数字').regex(/[^A-Za-z0-9]/, '新密码需包含特殊字符'),
}).refine(input => input.currentPassword !== input.newPassword, { message: '新密码不能与当前密码相同', path: ['newPassword'] })
export default defineEventHandler(async (event) => {
const user = await requireAdmin(event)
const input = passwordSchema.parse(await readBody(event))
const db = useDatabase()
const row = db.prepare('SELECT password_hash FROM admin_users WHERE id = ?').get(user.id) as { password_hash: string } | undefined
if (!row || !compareSync(input.currentPassword, row.password_hash)) throw createError({ statusCode: 400, statusMessage: '当前密码不正确' })
db.prepare('UPDATE admin_users SET password_hash = ?, updated_at = ? WHERE id = ?').run(hashSync(input.newPassword, 12), new Date().toISOString(), user.id)
writeAuditLog(event, { operatorId: user.id, action: 'password.change', entityType: 'admin_user', entityKey: String(user.id) })
return { success: true }
})

21
server/api/health.get.ts Normal file
View File

@@ -0,0 +1,21 @@
import { accessSync, constants, mkdirSync, statfsSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
export default defineEventHandler(() => {
const config = useRuntimeConfig()
useDatabase().prepare('SELECT 1').get()
const databaseDirectory = dirname(resolve(process.cwd(), config.databasePath))
const uploadsDirectory = resolve(process.cwd(), 'data', 'uploads')
mkdirSync(uploadsDirectory, { recursive: true })
accessSync(databaseDirectory, constants.R_OK | constants.W_OK)
accessSync(uploadsDirectory, constants.R_OK | constants.W_OK)
const disk = statfsSync(resolve(process.cwd(), 'data'))
const freeBytes = Number(disk.bavail) * Number(disk.bsize)
if (freeBytes < 256 * 1024 * 1024) throw createError({ statusCode: 503, statusMessage: '可用磁盘空间不足' })
return {
status: 'ok',
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION || 'development',
checks: { database: 'ok', uploads: 'writable', freeBytes },
}
})

View File

@@ -0,0 +1,43 @@
import { createReadStream, statSync } from 'node:fs'
import { resolve, sep } from 'node:path'
const contentTypes: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
}
export default defineEventHandler((event) => {
const requested = getRouterParam(event, 'path') || ''
const uploadsRoot = resolve(process.cwd(), 'data', 'uploads')
const legacyRoot = resolve(process.cwd(), 'data', 'media')
let mediaRoot = uploadsRoot
let filePath = resolve(mediaRoot, requested)
if (!filePath.startsWith(`${mediaRoot}${sep}`)) {
throw createError({ statusCode: 400, statusMessage: '无效的文件路径' })
}
let fileStat
try {
fileStat = statSync(filePath)
} catch {
const legacyPath = resolve(legacyRoot, requested)
if (!legacyPath.startsWith(`${legacyRoot}${sep}`)) throw createError({ statusCode: 400, statusMessage: '无效的文件路径' })
try { fileStat = statSync(legacyPath); filePath = legacyPath; mediaRoot = legacyRoot }
catch { throw createError({ statusCode: 404, statusMessage: '图片不存在' }) }
}
if (!fileStat.isFile()) {
throw createError({ statusCode: 404, statusMessage: '图片不存在' })
}
const extension = filePath.split('.').pop()?.toLowerCase() || ''
const contentType = contentTypes[extension]
if (!contentType) {
throw createError({ statusCode: 415, statusMessage: '不支持的文件类型' })
}
setHeader(event, 'Content-Type', contentType)
setHeader(event, 'Content-Length', fileStat.size)
setHeader(event, 'Cache-Control', 'public, max-age=31536000, immutable')
return sendStream(event, createReadStream(filePath))
})

View File

@@ -0,0 +1,6 @@
import { readSiteDocument } from '../../utils/db'
export default defineEventHandler((event) => {
setResponseHeader(event, 'Cache-Control', 'public, max-age=60, stale-while-revalidate=300')
return readSiteDocument('published')
})

View File

@@ -0,0 +1,52 @@
CREATE TABLE IF NOT EXISTS admin_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
failed_login_count INTEGER NOT NULL DEFAULT 0,
locked_until TEXT,
last_login_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS site_documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_key TEXT NOT NULL UNIQUE,
draft_json TEXT NOT NULL,
published_json TEXT NOT NULL,
updated_by INTEGER,
updated_at TEXT NOT NULL,
published_at TEXT,
FOREIGN KEY (updated_by) REFERENCES admin_users(id)
);
CREATE TABLE IF NOT EXISTS revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL,
entity_key TEXT NOT NULL,
version INTEGER NOT NULL,
snapshot_json TEXT NOT NULL,
operator_id INTEGER,
action TEXT NOT NULL,
comment TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (operator_id) REFERENCES admin_users(id),
UNIQUE(entity_type, entity_key, version)
);
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
operator_id INTEGER,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_key TEXT,
ip_address TEXT,
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
FOREIGN KEY (operator_id) REFERENCES admin_users(id)
);
CREATE INDEX IF NOT EXISTS idx_revisions_entity ON revisions(entity_type, entity_key, version DESC);
CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at DESC);

View File

@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS media_assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_key TEXT NOT NULL UNIQUE,
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
extension TEXT NOT NULL,
bytes INTEGER NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
original_path TEXT NOT NULL,
derived_path TEXT NOT NULL,
thumbnail_path TEXT NOT NULL,
alt_text TEXT NOT NULL DEFAULT '',
source_text TEXT NOT NULL DEFAULT '',
copyright_text TEXT NOT NULL DEFAULT '',
authorization_status TEXT NOT NULL DEFAULT 'pending' CHECK (authorization_status IN ('pending', 'authorized', 'restricted')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'trash', 'deleted')),
created_by INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT,
FOREIGN KEY (created_by) REFERENCES admin_users(id)
);
CREATE INDEX IF NOT EXISTS idx_media_assets_status_created ON media_assets(status, created_at DESC);

View File

@@ -0,0 +1,13 @@
export default defineEventHandler((event) => {
removeResponseHeader(event, 'x-powered-by')
setResponseHeaders(event, {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=()',
'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'",
})
if (useRuntimeConfig().sessionCookieSecure) {
setResponseHeader(event, 'Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
}
})

View File

@@ -0,0 +1,6 @@
export default defineEventHandler((event) => {
const baseUrl = String(useRuntimeConfig().public.siteUrl).replace(/\/$/, '')
setHeader(event, 'Content-Type', 'text/plain; charset=utf-8')
setHeader(event, 'Cache-Control', 'public, max-age=3600')
return `User-agent: *\nAllow: /\nDisallow: /admin\nDisallow: /api/admin\nDisallow: /api/auth\n\nSitemap: ${baseUrl}/sitemap.xml\n`
})

View File

@@ -0,0 +1,14 @@
import { readSiteDocument } from '../utils/db'
const escapeXml = (value: string) => value.replace(/[<>&'\"]/g, character => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' })[character]!)
export default defineEventHandler((event) => {
const baseUrl = String(useRuntimeConfig().public.siteUrl).replace(/\/$/, '')
const { data, publishedAt } = readSiteDocument('published')
const paths = ['/', '/luhui', '/products', '/news', '/statements', '/team', '/privacy', '/terms']
data.products.filter(item => item.status === 'published').forEach(item => paths.push(`/products/${item.slug}`))
data.articles.filter(item => item.status === 'published').forEach(item => paths.push(`/${item.type === 'statement' ? 'statements' : 'news'}/${item.slug}`))
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8')
setHeader(event, 'Cache-Control', 'public, max-age=300, stale-while-revalidate=600')
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${paths.map(path => ` <url><loc>${escapeXml(`${baseUrl}${path}`)}</loc><lastmod>${escapeXml(publishedAt.slice(0, 10))}</lastmod></url>`).join('\n')}\n</urlset>`
})

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未经授权不得以误导方式转载、改编或冒用本网站品牌与人物资料。',
},
}

14
tests/filing.test.ts Normal file
View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { getPoliceFilingUrl, MPS_FILING_URL } from '../app/utils/filing'
describe('公安备案链接', () => {
it('从备案文本中提取编号并生成查询链接', () => {
expect(getPoliceFilingUrl('沪公网安备 31010402001197号')).toBe(
'https://www.beian.gov.cn/portal/registerSystemInfo?recordcode=31010402001197',
)
})
it('没有编号时指向公安备案平台首页', () => {
expect(getPoliceFilingUrl('待配置')).toBe(MPS_FILING_URL)
})
})

40
tests/site-schema.test.ts Normal file
View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { defaultSiteDocument, siteDocumentSchema } from '../server/utils/site-schema'
describe('siteDocumentSchema', () => {
it('accepts the seeded phase-one website document', () => {
expect(siteDocumentSchema.parse(defaultSiteDocument)).toEqual(defaultSiteDocument)
expect(defaultSiteDocument.brand.name).toBe('慧遇书院')
})
it('rejects unsafe oversized content', () => {
const invalid = structuredClone(defaultSiteDocument)
invalid.hero.titleLine1 = '超'.repeat(21)
expect(() => siteDocumentSchema.parse(invalid)).toThrow()
})
it('contains routable published content', () => {
const parsed = siteDocumentSchema.parse(defaultSiteDocument)
expect(parsed.products.every(item => /^[a-z0-9-]+$/.test(item.slug))).toBe(true)
expect(parsed.articles.some(item => item.type === 'statement')).toBe(true)
expect(parsed.team.length).toBeGreaterThan(0)
})
it('rejects duplicate route slugs', () => {
const invalid = structuredClone(defaultSiteDocument)
invalid.products[1].slug = invalid.products[0].slug
expect(siteDocumentSchema.safeParse(invalid).success).toBe(false)
})
it('rejects unsafe external protocols', () => {
const invalid = structuredClone(defaultSiteDocument)
invalid.channels[0].url = 'javascript:alert(1)'
expect(siteDocumentSchema.safeParse(invalid).success).toBe(false)
})
it('allows channels to stay unlinked before official URLs are confirmed', () => {
const draft = structuredClone(defaultSiteDocument)
draft.channels.forEach(channel => { channel.url = '' })
expect(siteDocumentSchema.safeParse(draft).success).toBe(true)
})
})

Some files were not shown because too many files have changed in this diff Show More