Browse Source

feat(home): enhance user experience with new sections and quick create button

- Introduced `GuestHomeSection` and `LoggedInHomeSection` components to provide tailored content for guests and logged-in users.
- Added a quick create button for logged-in users to facilitate easy post creation.
- Updated the AppShell component to include computed properties for visibility of the quick create button based on user login status.
- Refactored the main index page to utilize the new components, improving the overall layout and user engagement.
- Implemented a new utility function for normalizing post slugs to ensure consistent URL formatting.

This update significantly enhances the homepage experience for both guests and registered users, promoting user interaction and content creation.
main
npmrun 3 weeks ago
parent
commit
4c31d81ed1
  1. 18
      app/components/AppShell.vue
  2. 209
      app/components/home/GuestHomeSection.vue
  3. 121
      app/components/home/LoggedInHomeSection.vue
  4. 76
      app/layouts/public.vue
  5. 268
      app/pages/index/index.vue
  6. 67
      app/pages/me/admin/index.vue
  7. 27
      app/pages/me/index.vue
  8. 169
      app/pages/me/posts/[id].vue
  9. 81
      app/pages/me/posts/index.vue
  10. 174
      app/pages/me/posts/new.vue
  11. 16
      app/utils/post-slug.ts
  12. BIN
      packages/drizzle-pkg/db.sqlite
  13. 2
      server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts
  14. 2
      server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts
  15. 113
      server/service/comment-notify/index.test.ts
  16. 80
      server/service/comment-notify/index.ts

18
app/components/AppShell.vue

@ -62,6 +62,7 @@ function navActive(to: string) {
}
const consoleNavActive = computed(() => navActive('/me'))
const showQuickCreate = computed(() => loggedIn.value && initialized.value)
const accountMenuItems = computed(() => {
const u = user.value
@ -86,8 +87,7 @@ const accountMenuItems = computed(() => {
}
if (u.role === 'admin') {
groups.push([
{ label: '应用配置', icon: 'i-lucide-sliders-horizontal', to: '/me/admin/config' },
{ label: '用户管理', icon: 'i-lucide-users', to: '/me/admin/users' },
{ label: '管理员中心', icon: 'i-lucide-shield-check', to: '/me/admin' },
])
}
return groups
@ -180,6 +180,17 @@ async function logout() {
</template>
<template v-else-if="loggedIn && user">
<UButton
v-if="showQuickCreate"
to="/me/posts/new"
color="primary"
variant="soft"
icon="i-lucide-square-pen"
size="sm"
class="hidden md:inline-flex"
>
写文章
</UButton>
<div class="hidden items-center gap-2 sm:flex">
<UBadge
v-if="user.role === 'admin'"
@ -219,8 +230,9 @@ async function logout() {
color="neutral"
variant="ghost"
icon="i-lucide-log-out"
label="退出"
:loading="logoutLoading"
class="hidden sm:inline-flex"
class="hidden md:inline-flex"
aria-label="退出登录"
@click="logout"
/>

209
app/components/home/GuestHomeSection.vue

@ -0,0 +1,209 @@
<script setup lang="ts">
defineProps<{
siteName?: string
allowRegister: boolean
}>()
const features = [
{
title: '个人资料',
desc: '生平叙事、头像、社交链接与公开主页 slug,支持分块可见性。',
icon: 'i-lucide-user-round',
to: '/me/profile',
},
{
title: 'Markdown 文章',
desc: '自写内容、slug、摘要与封面,每条可公开、私密或仅链接分享。',
icon: 'i-lucide-file-text',
to: '/me/posts',
},
{
title: '时光机',
desc: '按时间轴记录里程碑事件,与文章、阅读流在同一叙事里呈现。',
icon: 'i-lucide-history',
to: '/me/timeline',
},
{
title: 'RSS 收件箱',
desc: '订阅源仅自己可见;定时拉取、去重,条目默认可再设为公开展示。',
icon: 'i-lucide-rss',
to: '/me/rss',
},
] as const
const highlights = [
'多维内容管理',
'公开页可控展示',
'RSS 自动聚合',
'创作与记录一体化',
] as const
</script>
<template>
<div class="space-y-12">
<section
class="relative overflow-hidden rounded-3xl border border-primary/30 bg-gradient-to-br from-primary/20 via-default to-elevated px-6 py-10 sm:px-10 sm:py-12"
>
<div class="pointer-events-none absolute -right-20 -top-20 size-72 rounded-full bg-primary/20 blur-3xl" />
<div class="pointer-events-none absolute -bottom-24 -left-16 size-80 rounded-full bg-primary/10 blur-3xl" />
<div class="pointer-events-none absolute inset-0 opacity-40" style="background-image: radial-gradient(circle at 20% 20%, rgba(255,255,255,0.35), transparent 35%), radial-gradient(circle at 80% 10%, rgba(212,106,49,0.18), transparent 30%);" />
<div class="relative grid gap-6 lg:grid-cols-[1.2fr_0.8fr] lg:items-start">
<div class="space-y-5">
<UBadge color="primary" variant="soft" size="md" class="ring-1 ring-primary/30">
{{ siteName }} · Personal Control Center
</UBadge>
<h1 class="text-3xl font-extrabold tracking-tight text-highlighted sm:text-5xl">
你的内容宇宙
<span class="text-primary">从这里被看见</span>
</h1>
<p class="max-w-2xl text-base leading-relaxed text-toned sm:text-lg">
一站式管理资料文章时间线与 RSS可精细控制公开范围用更专业的方式建立你的个人品牌主页
</p>
<div class="flex flex-wrap gap-2">
<UBadge
v-for="item in highlights"
:key="item"
color="neutral"
variant="subtle"
size="sm"
class="ring-1 ring-default/80"
>
{{ item }}
</UBadge>
</div>
<div class="flex flex-wrap gap-3 pt-2">
<UButton
v-if="allowRegister"
to="/register"
size="lg"
color="primary"
icon="i-lucide-sparkles"
class="shadow-lg shadow-primary/20"
>
立即注册开始搭建主页
</UButton>
<UButton
to="/login"
size="lg"
color="neutral"
variant="outline"
icon="i-lucide-log-in"
>
我已有账号直接登录
</UButton>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
<UCard class="border-primary/30 bg-default/80 backdrop-blur">
<p class="text-xs text-muted">
内容表达
</p>
<p class="mt-1 text-lg font-semibold text-highlighted">
文章 + 时间线双叙事
</p>
</UCard>
<UCard class="border-primary/30 bg-default/80 backdrop-blur">
<p class="text-xs text-muted">
对外展示
</p>
<p class="mt-1 text-lg font-semibold text-highlighted">
专属公开主页地址
</p>
</UCard>
<UCard class="border-primary/30 bg-default/80 backdrop-blur">
<p class="text-xs text-muted">
信息输入
</p>
<p class="mt-1 text-lg font-semibold text-highlighted">
RSS 自动同步与整理
</p>
</UCard>
<UCard class="border-primary/30 bg-default/80 backdrop-blur">
<p class="text-xs text-muted">
权限机制
</p>
<p class="mt-1 text-lg font-semibold text-highlighted">
每条内容独立可见性
</p>
</UCard>
</div>
</div>
</section>
<section class="space-y-4">
<div>
<h2 class="text-2xl font-semibold text-highlighted">
你将获得的能力
</h2>
<p class="mt-1 text-sm text-muted sm:text-base">
从个人展示到持续创作再到信息摄取形成完整的个人内容工作流
</p>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<UCard
v-for="item in features"
:key="item.title"
class="group border-default/80 transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-lg hover:shadow-primary/10"
>
<div class="flex gap-4">
<span class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary ring-1 ring-primary/20">
<UIcon :name="item.icon" class="size-6" />
</span>
<div class="min-w-0 flex-1 space-y-2">
<h3 class="font-semibold text-highlighted">
<NuxtLink
to="/login"
class="rounded-sm hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
{{ item.title }}
</NuxtLink>
</h3>
<p class="text-sm leading-relaxed text-muted">
{{ item.desc }}
</p>
<UButton
to="/login"
size="xs"
variant="soft"
class="px-0"
trailing-icon="i-lucide-arrow-right"
>
登录后立即体验
</UButton>
</div>
</div>
</UCard>
</div>
</section>
<section class="rounded-2xl border border-primary/25 bg-gradient-to-r from-primary/10 via-elevated to-default px-6 py-8">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<h3 class="text-lg font-semibold text-highlighted">
准备好搭建你的公开主页了吗
</h3>
<p class="mt-1 text-sm text-muted leading-relaxed">
新账号采用邀请制开通开通后即可开始创建并发布你的个人内容空间
</p>
</div>
<div class="flex flex-wrap gap-2">
<UButton
v-if="allowRegister"
to="/register"
color="primary"
icon="i-lucide-user-plus"
>
立即注册
</UButton>
<UButton to="/login" color="neutral" variant="outline">
去登录
</UButton>
</div>
</div>
</section>
</div>
</template>

121
app/components/home/LoggedInHomeSection.vue

@ -0,0 +1,121 @@
<script setup lang="ts">
type HomeUser = {
username: string
nickname?: string | null
avatar?: string | null
role: 'admin' | 'user' | string
publicSlug?: string | null
}
defineProps<{
user: HomeUser
}>()
</script>
<template>
<div class="space-y-4">
<section class="rounded-2xl border border-default bg-gradient-to-br from-primary/5 via-default to-elevated px-6 py-6 sm:px-8">
<div class="grid gap-4 lg:grid-cols-3">
<UCard class="ring-1 ring-default/60 lg:col-span-2">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="flex min-w-0 items-start gap-3">
<UAvatar :src="user.avatar || undefined" :alt="user.username" size="lg" class="ring-1 ring-default" />
<div class="min-w-0">
<p class="text-xs font-medium text-muted">
当前用户
</p>
<p class="truncate text-lg font-semibold text-highlighted">
{{ user.nickname?.trim() || user.username }}
</p>
<NuxtLink
to="/me/profile"
class="block truncate text-sm text-muted transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
@{{ user.username }} · {{ user.role === 'admin' ? '管理员' : '用户' }}
</NuxtLink>
<NuxtLink
v-if="user.publicSlug"
:to="`/@${user.publicSlug}`"
target="_blank"
class="mt-1 block text-sm tabular-nums text-[#d46a31] transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
{{ `/@${user.publicSlug}` }}
</NuxtLink>
<NuxtLink
v-else
to="/me/profile"
class="mt-1 block text-sm text-muted transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
未设置公开主页地址去设置
</NuxtLink>
</div>
</div>
<div class="flex flex-wrap gap-2">
<UButton to="/me" size="sm" icon="i-lucide-layout-dashboard">
控制台
</UButton>
<UButton to="/me/profile" size="sm" color="neutral" variant="outline">
编辑资料
</UButton>
<UButton
v-if="user.publicSlug"
:to="`/@${user.publicSlug}`"
target="_blank"
size="sm"
color="neutral"
variant="outline"
icon="i-lucide-external-link"
>
公开主页
</UButton>
</div>
</div>
</UCard>
<UCard>
<p class="text-xs font-medium text-muted">
管理与常用
</p>
<div class="mt-3 flex flex-wrap gap-2">
<UButton to="/me/posts" size="xs" variant="soft">
文章
</UButton>
<UButton to="/me/timeline" size="xs" variant="soft">
时光机
</UButton>
<UButton to="/me/rss" size="xs" variant="soft">
RSS
</UButton>
<UButton
v-if="user.role === 'admin'"
to="/me/admin/config"
size="xs"
color="primary"
variant="soft"
>
应用配置
</UButton>
<UButton
v-if="user.role === 'admin'"
to="/me/admin/users"
size="xs"
color="primary"
variant="soft"
>
用户管理
</UButton>
</div>
</UCard>
</div>
</section>
<UAlert
v-if="!user.publicSlug"
color="warning"
variant="subtle"
title="还没有公开主页地址"
description="在「资料」中设置 public slug 后,访客即可通过 /@你的地址 访问你的公开内容。"
icon="i-lucide-alert-circle"
/>
</div>
</template>

76
app/layouts/public.vue

@ -7,8 +7,10 @@ import {
import { unwrapApiBody, type ApiResponse } from '../utils/http/factory'
const route = useRoute()
const { loggedIn, user, refresh, initialized } = useAuthSession()
const { siteName } = useGlobalConfig()
const { loggedIn, user, refresh, clear, initialized } = useAuthSession()
const { fetchData } = useClientApi()
const { siteName, allowRegister } = useGlobalConfig()
const logoutLoading = ref(false)
const showPublicLayoutToggle = computed(() => /^\/@[^/]+$/.test(route.path))
const profileSlug = computed(() => {
@ -73,6 +75,22 @@ const headerBrandIconUrl = computed(() => {
return u.trim()
})
const showPublicLogin = computed(() => !loggedIn.value && initialized.value)
const showPublicRegister = computed(() =>
!loggedIn.value && initialized.value && allowRegister.value,
)
async function logout() {
logoutLoading.value = true
try {
await fetchData<{ success: boolean }>('/api/auth/logout', { method: 'POST' })
clear()
await navigateTo('/')
} finally {
logoutLoading.value = false
}
}
type LayoutBySlug = Record<string, PublicHomeLayoutMode>
const layoutBySlug = useCookie<LayoutBySlug>('public_home_layout', {
@ -114,8 +132,8 @@ onMounted(() => {
<template>
<UApp>
<div class="min-h-screen bg-default text-default">
<header class="sticky top-0 z-40 border-b border-default/80 bg-default/95 backdrop-blur">
<UContainer class="flex h-14 items-center justify-between gap-3 sm:gap-4">
<header class="sticky top-0 z-40 border-b border-default/70 bg-default/90 backdrop-blur">
<UContainer class="flex h-12 items-center justify-between gap-3 sm:gap-4">
<NuxtLink
:to="headerBrandTo"
class="flex min-w-0 items-center gap-2 font-semibold tracking-tight text-highlighted"
@ -132,19 +150,16 @@ onMounted(() => {
</span>
<span class="truncate">{{ headerBrandTitle }}</span>
</NuxtLink>
<div class="flex shrink-0 items-center gap-2 text-sm sm:gap-3">
<NuxtLink to="/" class="text-muted hover:text-default whitespace-nowrap">
站点首页
</NuxtLink>
<div class="flex shrink-0 items-center gap-1.5 text-sm sm:gap-2">
<div
v-if="showPublicLayoutToggle"
class="inline-flex rounded-lg border border-default bg-elevated/40 p-0.5 shadow-sm"
class="inline-flex rounded-md border border-default/80 bg-elevated/30 p-0.5"
>
<UButton
size="xs"
:color="publicLayoutMode === 'showcase' ? 'primary' : 'neutral'"
:variant="publicLayoutMode === 'showcase' ? 'solid' : 'ghost'"
class="rounded-md"
class="rounded-sm"
@click="publicLayoutMode = 'showcase'"
>
<UIcon name="i-lucide-layout-template" class="size-3.5 sm:mr-1" />
@ -154,7 +169,7 @@ onMounted(() => {
size="xs"
:color="publicLayoutMode === 'detailed' ? 'primary' : 'neutral'"
:variant="publicLayoutMode === 'detailed' ? 'solid' : 'ghost'"
class="rounded-md"
class="rounded-sm"
@click="publicLayoutMode = 'detailed'"
>
<UIcon name="i-lucide-book-open" class="size-3.5 sm:mr-1" />
@ -164,13 +179,38 @@ onMounted(() => {
<template v-if="!initialized">
<USkeleton class="h-5 w-12 rounded" />
</template>
<NuxtLink
v-else-if="showPublicConsoleLink"
to="/me"
class="text-muted hover:text-default whitespace-nowrap"
>
控制台
</NuxtLink>
<template v-else-if="loggedIn">
<NuxtLink
v-if="showPublicConsoleLink"
to="/me"
class="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted transition-colors hover:text-default"
>
<UIcon name="i-lucide-layout-dashboard" class="size-3.5" />
控制台
</NuxtLink>
<UButton
color="neutral"
variant="ghost"
size="xs"
icon="i-lucide-log-out"
:loading="logoutLoading"
@click="logout"
>
退出
</UButton>
</template>
<template v-else-if="showPublicLogin">
<NuxtLink to="/login" class="rounded-md px-2 py-1 text-xs text-muted transition-colors hover:text-default">
登录
</NuxtLink>
<NuxtLink
v-if="showPublicRegister"
to="/register"
class="rounded-md border border-default/80 px-2 py-1 text-xs text-muted transition-colors hover:text-default"
>
注册
</NuxtLink>
</template>
</div>
</UContainer>
</header>

268
app/pages/index/index.vue

@ -1,272 +1,18 @@
<script setup lang="ts">
import { useAuthSession } from '../../composables/useAuthSession'
const { loggedIn, user, clear } = useAuthSession()
const { fetchData } = useClientApi()
import GuestHomeSection from '../../components/home/GuestHomeSection.vue'
import LoggedInHomeSection from '../../components/home/LoggedInHomeSection.vue'
const { loggedIn, user } = useAuthSession()
const { allowRegister, siteName } = useGlobalConfig()
usePageTitle('首页')
const logoutLoading = ref(false)
const features = [
{
title: '个人资料',
desc: '生平叙事、头像、社交链接与公开主页 slug,支持分块可见性。',
icon: 'i-lucide-user-round',
to: '/me/profile',
},
{
title: 'Markdown 文章',
desc: '自写内容、slug、摘要与封面,每条可公开、私密或仅链接分享。',
icon: 'i-lucide-file-text',
to: '/me/posts',
},
{
title: '时光机',
desc: '按时间轴记录里程碑事件,与文章、阅读流在同一叙事里呈现。',
icon: 'i-lucide-history',
to: '/me/timeline',
},
{
title: 'RSS 收件箱',
desc: '订阅源仅自己可见;定时拉取、去重,条目默认可再设为公开展示。',
icon: 'i-lucide-rss',
to: '/me/rss',
},
] as const
async function logout() {
logoutLoading.value = true
try {
await fetchData<{ success: boolean }>('/api/auth/logout', { method: 'POST' })
clear()
await navigateTo('/')
} finally {
logoutLoading.value = false
}
}
</script>
<template>
<div class="mx-auto max-w-6xl space-y-12 pb-4">
<!-- Hero -->
<section class="relative overflow-hidden rounded-2xl border border-default bg-gradient-to-br from-primary/5 via-default to-elevated px-6 py-12 sm:px-10 sm:py-16">
<div class="pointer-events-none absolute -right-20 -top-20 size-64 rounded-full bg-primary/10 blur-3xl" />
<div class="pointer-events-none absolute -bottom-24 -left-16 size-72 rounded-full bg-primary/5 blur-3xl" />
<div class="relative max-w-2xl space-y-4">
<UBadge color="primary" variant="subtle" size="md">
个人数据中心
</UBadge>
<h1 class="text-3xl font-bold tracking-tight text-highlighted sm:text-4xl">
把你的故事文章与阅读收进一个地方
</h1>
<p class="text-base text-muted sm:text-lg leading-relaxed">
{{ siteName }} 面向多用户邀请制场景每位用户独立资料与订阅公开主页可只展示你愿意公开的部分RSS 在后台静默同步
</p>
<div class="flex flex-wrap gap-3 pt-2">
<template v-if="loggedIn">
<UButton to="/me" size="lg" icon="i-lucide-layout-dashboard">
进入控制台
</UButton>
<UButton
v-if="user?.publicSlug"
:to="`/@${user.publicSlug}`"
target="_blank"
size="lg"
color="neutral"
variant="outline"
icon="i-lucide-external-link"
>
预览公开主页
</UButton>
<UButton
v-else
to="/me/profile"
size="lg"
color="neutral"
variant="outline"
icon="i-lucide-link"
>
设置公开链接
</UButton>
</template>
<template v-else>
<UButton to="/login" size="lg" icon="i-lucide-log-in">
登录使用
</UButton>
<UButton
v-if="allowRegister"
to="/register"
size="lg"
color="neutral"
variant="outline"
>
注册账号
</UButton>
</template>
</div>
</div>
</section>
<!-- 已登录摘要条 -->
<section v-if="loggedIn && user" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UCard class="ring-1 ring-default/60">
<div class="flex items-start gap-3">
<UAvatar :src="user.avatar || undefined" :alt="user.username" size="md" class="ring-1 ring-default" />
<div class="min-w-0 flex-1">
<p class="text-xs font-medium text-muted">
当前用户
</p>
<p class="truncate font-semibold text-highlighted">
{{ user.nickname?.trim() || user.username }}
</p>
<p class="truncate text-xs text-muted">
@{{ user.username }} · {{ user.role === 'admin' ? '管理员' : '用户' }}
</p>
</div>
</div>
</UCard>
<UCard>
<p class="text-sm font-medium text-muted">
公开主页
</p>
<p
class="mt-1 text-base font-semibold leading-6 tabular-nums"
:class="user.publicSlug ? 'text-[#d46a31]' : 'text-muted'"
>
{{ user.publicSlug ? `/@${user.publicSlug}` : '尚未设置' }}
</p>
<UButton to="/me/profile" size="xs" variant="link" class="mt-2 px-0">
去资料页配置
</UButton>
</UCard>
<UCard class="sm:col-span-2">
<p class="text-xs font-medium text-muted">
快捷入口
</p>
<div class="mt-2 flex flex-wrap gap-2">
<UButton to="/me/posts" size="xs" variant="soft">
文章
</UButton>
<UButton to="/me/timeline" size="xs" variant="soft">
时光机
</UButton>
<UButton to="/me/rss" size="xs" variant="soft">
RSS
</UButton>
<UButton to="/me/profile" size="xs" variant="soft">
资料
</UButton>
<UButton
v-if="user.role === 'admin'"
to="/me/admin/config"
size="xs"
color="primary"
variant="soft"
>
应用配置
</UButton>
<UButton
v-if="user.role === 'admin'"
to="/me/admin/users"
size="xs"
color="primary"
variant="soft"
>
用户管理
</UButton>
</div>
</UCard>
</section>
<UAlert
v-if="loggedIn && user && !user.publicSlug"
color="warning"
variant="subtle"
title="还没有公开主页地址"
description="在「资料」中设置 public slug 后,访客即可通过 /@你的地址 访问你的公开内容。"
icon="i-lucide-alert-circle"
/>
<!-- 功能矩阵 -->
<section class="space-y-4">
<div>
<h2 class="text-xl font-semibold text-highlighted">
你能做什么
</h2>
<p class="mt-1 text-sm text-muted">
四大模块覆盖展示创作与阅读输入权限与可见性按条目细粒度控制
</p>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<UCard
v-for="item in features"
:key="item.title"
class="group transition-shadow hover:shadow-md"
>
<div class="flex gap-4">
<span class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<UIcon :name="item.icon" class="size-6" />
</span>
<div class="min-w-0 flex-1 space-y-2">
<h3 class="font-semibold text-highlighted">
<NuxtLink
:to="loggedIn ? item.to : '/login'"
class="rounded-sm hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
{{ item.title }}
</NuxtLink>
</h3>
<p class="text-sm leading-relaxed text-muted">
{{ item.desc }}
</p>
<UButton
:to="loggedIn ? item.to : '/login'"
size="xs"
variant="link"
class="px-0"
trailing-icon="i-lucide-arrow-right"
>
{{ loggedIn ? '打开' : '登录后使用' }}
</UButton>
</div>
</div>
</UCard>
</div>
</section>
<!-- 访客说明 -->
<section v-if="!loggedIn" class="rounded-xl border border-dashed border-default bg-elevated/30 px-6 py-8">
<h3 class="font-semibold text-highlighted">
访客说明
</h3>
<p class="mt-2 max-w-2xl text-sm text-muted leading-relaxed">
本站默认需登录后使用控制台若你已有账号请点击登录新账号由管理员在后台创建邀请制
</p>
<div class="mt-4 flex flex-wrap gap-2">
<UButton to="/login">
登录
</UButton>
<UButton v-if="allowRegister" to="/register" color="neutral" variant="outline">
注册
</UButton>
</div>
</section>
<!-- 已登录底部操作 -->
<section v-if="loggedIn" class="flex flex-wrap items-center justify-between gap-4 rounded-xl border border-default bg-elevated/20 px-4 py-4">
<p class="text-sm text-muted">
需要退出当前会话
</p>
<div class="flex gap-2">
<UButton to="/api/config/me" target="_blank" variant="outline" color="neutral" size="sm">
配置 API调试
</UButton>
<UButton color="neutral" :loading="logoutLoading" size="sm" @click="logout">
退出登录
</UButton>
</div>
</section>
<div class="mx-auto max-w-6xl pb-4">
<LoggedInHomeSection v-if="loggedIn && user" :user="user" />
<GuestHomeSection v-else :site-name="siteName" :allow-register="allowRegister" />
</div>
</template>

67
app/pages/me/admin/index.vue

@ -0,0 +1,67 @@
<script setup lang="ts">
import { useAuthSession } from '../../../composables/useAuthSession'
const { user } = useAuthSession()
usePageTitle('管理员中心')
</script>
<template>
<UContainer class="py-8 space-y-6">
<div>
<h1 class="text-2xl font-semibold">
管理员中心
</h1>
<p class="mt-1 text-sm text-muted">
站点级配置用户管理与媒体存储校验入口
</p>
</div>
<UAlert
v-if="user?.role !== 'admin'"
color="error"
variant="subtle"
title="无权限访问"
description="该页面仅管理员可访问。"
icon="i-lucide-shield-alert"
/>
<div v-else class="grid gap-3 sm:grid-cols-2">
<UCard>
<div class="font-medium">
应用配置
</div>
<p class="mt-1 text-sm text-muted">
站点名称注册开关
</p>
<UButton to="/me/admin/config" class="mt-3" size="sm">
打开
</UButton>
</UCard>
<UCard>
<div class="font-medium">
用户管理
</div>
<p class="mt-1 text-sm text-muted">
用户列表与权限管理
</p>
<UButton to="/me/admin/users" class="mt-3" size="sm">
打开
</UButton>
</UCard>
<UCard>
<div class="font-medium">
文章媒体存储校验
</div>
<p class="mt-1 text-sm text-muted">
磁盘与 media_assets 一致性
</p>
<UButton to="/me/admin/media-storage" class="mt-3" size="sm">
打开
</UButton>
</UCard>
</div>
</UContainer>
</template>

27
app/pages/me/index.vue

@ -81,25 +81,6 @@ onMounted(async () => {
收件箱
</UButton>
</UCard>
<UCard v-if="user?.role === 'admin'">
<div class="font-medium">
应用配置
</div>
<p class="text-sm text-muted mt-1">
站点名称注册开关
</p>
<UButton to="/me/admin/config" class="mt-3" size="sm">
打开
</UButton>
</UCard>
<UCard v-if="user?.role === 'admin'">
<div class="font-medium">
用户管理
</div>
<UButton to="/me/admin/users" class="mt-3" size="sm">
打开
</UButton>
</UCard>
<UCard>
<div class="font-medium">
媒体
@ -113,13 +94,13 @@ onMounted(async () => {
</UCard>
<UCard v-if="user?.role === 'admin'">
<div class="font-medium">
文章媒体存储校验
管理员中心
</div>
<p class="text-sm text-muted mt-1">
磁盘与 media_assets 一致性
统一进入应用配置用户管理媒体存储校验
</p>
<UButton to="/me/admin/media-storage" class="mt-3" size="sm">
打开
<UButton to="/me/admin" class="mt-3" size="sm">
进入
</UButton>
</UCard>
</div>

169
app/pages/me/posts/[id].vue

@ -1,5 +1,6 @@
<script setup lang="ts">
import { useAuthSession } from '../../../composables/useAuthSession'
import { normalizePostSlugCandidate } from '../../../utils/post-slug'
const route = useRoute()
const id = computed(() => route.params.id as string)
@ -17,6 +18,12 @@ const state = reactive({
})
const loading = ref(true)
const saving = ref(false)
const visibilityItems = [
{ label: '私密', value: 'private' },
{ label: '公开', value: 'public' },
{ label: '仅链接', value: 'unlisted' },
]
const bodyLength = computed(() => state.bodyMarkdown.trim().length)
const publicPostHref = computed(() => {
const ps = user.value?.publicSlug
@ -62,6 +69,30 @@ usePageTitle(() => {
return t ? [t, '编辑'] : ['编辑文章']
})
function generateSlugFromTitle() {
if (!state.title.trim()) {
toast.add({ title: '请先填写标题', color: 'warning' })
return
}
const previous = state.slug
const normalized = normalizePostSlugCandidate(state.title)
state.slug = normalized
if (!state.slug) {
toast.add({ title: '未生成 slug,请检查标题内容', color: 'warning' })
return
}
if (state.slug === previous) {
toast.add({ title: 'slug 未变化', color: 'neutral' })
return
}
toast.add({ title: '已生成 slug', color: 'success' })
}
async function save() {
saving.value = true
try {
@ -101,7 +132,7 @@ const shareUrl = computed(() => {
</script>
<template>
<UContainer class="py-8 max-w-6xl space-y-6">
<UContainer class="py-8 max-w-[1600px] space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-semibold tracking-tight">
编辑文章
@ -119,6 +150,9 @@ const shareUrl = computed(() => {
<UButton to="/me/posts" variant="ghost" color="neutral" size="sm">
返回列表
</UButton>
<UButton type="submit" form="edit-post-form" :loading="saving" size="sm">
保存文章
</UButton>
</div>
</div>
@ -127,62 +161,87 @@ const shareUrl = computed(() => {
</div>
<template v-else>
<UForm :state="state" class="space-y-6" @submit.prevent="save">
<UCard :ui="{ body: 'p-4 sm:p-6' }">
<PostBodyMarkdownEditor v-model="state.bodyMarkdown" />
<UFormField label="正文" name="bodyMarkdown" required class="sr-only" />
</UCard>
<UCard :ui="{ body: 'p-4 sm:p-6 space-y-4' }">
<UCollapsible :unmount-on-hide="false">
<UButton
type="button"
color="neutral"
variant="subtle"
block
class="justify-between font-medium"
label="文章设置"
trailing
trailing-icon="i-lucide-chevron-down"
/>
<template #content>
<div class="pt-4 space-y-4 border-t border-default mt-4">
<UAlert
v-if="shareUrl"
title="仅链接分享"
:description="shareUrl"
<UForm
id="edit-post-form"
:state="state"
class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_280px]"
@submit.prevent="save"
>
<div class="space-y-6">
<UCard :ui="{ body: 'p-4 sm:p-6 space-y-4' }">
<UFormField label="标题" name="title" required class="w-full">
<UInput
v-model="state.title"
class="w-full"
placeholder="例如:我的 2026 开发工作流复盘"
/>
</UFormField>
<UFormField label="摘要" name="excerpt" required class="w-full">
<UTextarea
v-model="state.excerpt"
class="w-full"
:rows="3"
autoresize
placeholder="一句话概括文章核心内容,便于列表页快速浏览。"
/>
</UFormField>
</UCard>
<UCard :ui="{ body: 'p-4 sm:p-6 space-y-3' }">
<div class="flex items-center justify-between gap-3">
<h2 class="text-base font-medium">
正文内容
</h2>
<span class="text-xs text-muted">字数 {{ bodyLength }}</span>
</div>
<PostBodyMarkdownEditor v-model="state.bodyMarkdown" />
<UFormField label="正文" name="bodyMarkdown" required class="sr-only" />
</UCard>
</div>
<div class="space-y-6 xl:sticky xl:top-20 xl:self-start">
<UCard :ui="{ body: 'p-4 sm:p-5 space-y-4' }">
<h2 class="text-base font-medium">
发布设置
</h2>
<UFormField label="slug" name="slug" required>
<div class="flex items-center gap-2">
<UInput v-model="state.slug" placeholder="my-post-slug" class="flex-1" />
<UButton
type="button"
variant="soft"
color="neutral"
icon="i-lucide-wand-sparkles"
label="生成"
@click="generateSlugFromTitle"
/>
<UFormField label="标题" name="title" required>
<UInput v-model="state.title" />
</UFormField>
<UFormField label="slug" name="slug" required>
<UInput v-model="state.slug" />
</UFormField>
<UFormField label="摘要" name="excerpt" required>
<UInput v-model="state.excerpt" />
</UFormField>
<UFormField label="可见性" name="visibility">
<USelect
v-model="state.visibility"
:items="[
{ label: '私密', value: 'private' },
{ label: '公开', value: 'public' },
{ label: '仅链接', value: 'unlisted' },
]"
/>
</UFormField>
</div>
</template>
</UCollapsible>
</UCard>
<div class="flex flex-wrap gap-2">
<UButton type="submit" :loading="saving">
保存
</UButton>
<UButton color="error" variant="soft" type="button" @click="remove">
删除
</UButton>
</UFormField>
<UFormField label="可见性" name="visibility">
<USelect v-model="state.visibility" :items="visibilityItems" />
</UFormField>
<UAlert
v-if="shareUrl"
title="仅链接分享"
:description="shareUrl"
/>
</UCard>
<UCard :ui="{ body: 'p-4 sm:p-5 space-y-3' }">
<h2 class="text-base font-medium">
操作
</h2>
<UButton type="submit" :loading="saving" block>
保存文章
</UButton>
<UButton to="/me/posts" variant="ghost" color="neutral" block>
取消并返回
</UButton>
<UButton color="error" variant="soft" type="button" block @click="remove">
删除文章
</UButton>
</UCard>
</div>
</UForm>
</template>

81
app/pages/me/posts/index.vue

@ -4,9 +4,11 @@ import { useAuthSession } from '../../../composables/useAuthSession'
usePageTitle('我的文章')
type Row = { id: number; title: string; slug: string; visibility: string }
type ViewMode = 'list' | 'card'
const posts = ref<Row[]>([])
const loading = ref(true)
const viewMode = ref<ViewMode>('card')
const { user, refresh: refreshAuth } = useAuthSession()
const { fetchData } = useClientApi()
@ -32,24 +34,56 @@ function postDetailHref(slug: string, visibility: string) {
}
return `/@${ps}/posts/${encodeURIComponent(slug)}`
}
function visibilityLabel(visibility: string) {
if (visibility === 'public') {
return '公开'
}
if (visibility === 'unlisted') {
return '仅链接'
}
return '私密'
}
</script>
<template>
<UContainer class="py-8 space-y-4 max-w-3xl">
<UContainer class="py-8 space-y-4 max-w-6xl">
<div class="flex flex-wrap justify-between items-center gap-3">
<h1 class="text-2xl font-semibold">
文章
</h1>
<UButton to="/me/posts/new">
新建
</UButton>
<div class="flex items-center gap-2">
<UButtonGroup size="sm" class="rounded-lg border border-default p-0.5 bg-elevated/40">
<UButton
:variant="viewMode === 'list' ? 'soft' : 'ghost'"
color="neutral"
icon="i-lucide-list"
:class="viewMode === 'list' ? 'shadow-sm' : 'text-muted'"
@click="viewMode = 'list'"
>
列表
</UButton>
<UButton
:variant="viewMode === 'card' ? 'soft' : 'ghost'"
color="neutral"
icon="i-lucide-layout-grid"
:class="viewMode === 'card' ? 'shadow-sm' : 'text-muted'"
@click="viewMode = 'card'"
>
卡片
</UButton>
</UButtonGroup>
<UButton to="/me/posts/new">
新建
</UButton>
</div>
</div>
<div v-if="loading" class="text-muted">
加载中
</div>
<UEmpty v-else-if="!posts.length" title="暂无文章" description="创建第一篇 Markdown 文章" />
<ul v-else class="space-y-2">
<ul v-else-if="viewMode === 'list'" class="space-y-2">
<li
v-for="p in posts"
:key="p.id"
@ -60,7 +94,7 @@ function postDetailHref(slug: string, visibility: string) {
{{ p.title }}
</div>
<div class="text-xs text-muted">
/{{ p.slug }} · {{ p.visibility }}
/{{ p.slug }} · {{ visibilityLabel(p.visibility) }}
</div>
</div>
<div class="flex flex-wrap gap-1 justify-end">
@ -79,5 +113,40 @@ function postDetailHref(slug: string, visibility: string) {
</div>
</li>
</ul>
<div v-else class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<UCard
v-for="p in posts"
:key="p.id"
:ui="{ body: 'p-4 space-y-3', footer: 'px-4 py-3 border-t border-default' }"
>
<div class="space-y-2">
<div class="font-medium line-clamp-2 min-h-12">
{{ p.title }}
</div>
<div class="text-xs text-muted break-all">
/{{ p.slug }}
</div>
<UBadge size="sm" color="neutral" variant="soft">
{{ visibilityLabel(p.visibility) }}
</UBadge>
</div>
<template #footer>
<div class="flex flex-wrap gap-2 justify-end">
<UButton
v-if="postDetailHref(p.slug, p.visibility)"
:to="postDetailHref(p.slug, p.visibility)"
size="xs"
variant="soft"
color="neutral"
>
详情
</UButton>
<UButton :to="`/me/posts/${p.id}`" size="xs" variant="ghost">
编辑
</UButton>
</div>
</template>
</UCard>
</div>
</UContainer>
</template>

174
app/pages/me/posts/new.vue

@ -1,4 +1,6 @@
<script setup lang="ts">
import { normalizePostSlugCandidate } from '../../../utils/post-slug'
usePageTitle('新建文章')
const { fetchData } = useClientApi()
@ -12,6 +14,37 @@ const state = reactive({
visibility: 'private',
})
const loading = ref(false)
const visibilityItems = [
{ label: '私密', value: 'private' },
{ label: '公开', value: 'public' },
{ label: '仅链接', value: 'unlisted' },
]
const bodyLength = computed(() => state.bodyMarkdown.trim().length)
function generateSlugFromTitle() {
if (!state.title.trim()) {
toast.add({ title: '请先填写标题', color: 'warning' })
return
}
const previous = state.slug
const normalized = normalizePostSlugCandidate(state.title)
state.slug = normalized
if (!state.slug) {
toast.add({ title: '未生成 slug,请检查标题内容', color: 'warning' })
return
}
if (state.slug === previous) {
toast.add({ title: 'slug 未变化', color: 'neutral' })
return
}
toast.add({ title: '已生成 slug', color: 'success' })
}
async function submit() {
loading.value = true
@ -36,64 +69,99 @@ async function submit() {
</script>
<template>
<UContainer class="py-8 max-w-6xl space-y-6">
<UContainer class="py-8 max-w-[1600px] space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-semibold tracking-tight">
新建文章
</h1>
<UButton to="/me/posts" variant="ghost" color="neutral">
返回列表
</UButton>
<div class="space-y-1">
<h1 class="text-2xl font-semibold tracking-tight">
新建文章
</h1>
<p class="text-sm text-muted">
先填写标题和正文再在右侧完成发布设置
</p>
</div>
<div class="flex items-center gap-2">
<UButton to="/me/posts" variant="ghost" color="neutral">
返回列表
</UButton>
<UButton type="submit" form="new-post-form" :loading="loading">
创建文章
</UButton>
</div>
</div>
<UForm :state="state" class="space-y-6" @submit.prevent="submit">
<UCard :ui="{ body: 'p-4 sm:p-6' }">
<PostBodyMarkdownEditor v-model="state.bodyMarkdown" />
<UFormField label="正文" name="bodyMarkdown" required class="sr-only" />
</UCard>
<UCard :ui="{ body: 'p-4 sm:p-6 space-y-4' }">
<UCollapsible :unmount-on-hide="false" :default-open="true">
<UButton
type="button"
color="neutral"
variant="subtle"
block
class="justify-between font-medium"
label="文章设置"
trailing
trailing-icon="i-lucide-chevron-down"
/>
<template #content>
<div class="pt-4 space-y-4 border-t border-default mt-4">
<UFormField label="标题" name="title" required>
<UInput v-model="state.title" />
</UFormField>
<UFormField label="slug" name="slug" required>
<UInput v-model="state.slug" />
</UFormField>
<UFormField label="摘要" name="excerpt" required>
<UInput v-model="state.excerpt" />
</UFormField>
<UFormField label="可见性" name="visibility">
<USelect
v-model="state.visibility"
:items="[
{ label: '私密', value: 'private' },
{ label: '公开', value: 'public' },
{ label: '仅链接', value: 'unlisted' },
]"
/>
</UFormField>
<UForm
id="new-post-form"
:state="state"
class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_280px]"
@submit.prevent="submit"
>
<div class="space-y-6">
<UCard :ui="{ body: 'p-4 sm:p-6 space-y-4' }">
<UFormField label="标题" name="title" required class="w-full">
<UInput
v-model="state.title"
class="w-full"
placeholder="例如:我的 2026 开发工作流复盘"
/>
</UFormField>
<UFormField label="摘要" name="excerpt" required class="w-full">
<UTextarea
v-model="state.excerpt"
class="w-full"
:rows="3"
autoresize
placeholder="一句话概括文章核心内容,便于列表页快速浏览。"
/>
</UFormField>
</UCard>
<UCard :ui="{ body: 'p-4 sm:p-6 space-y-3' }">
<div class="flex items-center justify-between gap-3">
<h2 class="text-base font-medium">
正文内容
</h2>
<span class="text-xs text-muted">字数 {{ bodyLength }}</span>
</div>
<PostBodyMarkdownEditor v-model="state.bodyMarkdown" />
<UFormField label="正文" name="bodyMarkdown" required class="sr-only" />
</UCard>
</div>
<div class="space-y-6 xl:sticky xl:top-20 xl:self-start">
<UCard :ui="{ body: 'p-4 sm:p-5 space-y-4' }">
<h2 class="text-base font-medium">
发布设置
</h2>
<UFormField label="slug" name="slug" required>
<div class="flex items-center gap-2">
<UInput v-model="state.slug" placeholder="my-post-slug" class="flex-1" />
<UButton
type="button"
variant="soft"
color="neutral"
icon="i-lucide-wand-sparkles"
label="生成"
@click="generateSlugFromTitle"
/>
</div>
</template>
</UCollapsible>
</UCard>
</UFormField>
<UFormField label="可见性" name="visibility">
<USelect v-model="state.visibility" :items="visibilityItems" />
</UFormField>
</UCard>
<div class="flex flex-wrap gap-2">
<UButton type="submit" :loading="loading">
创建
</UButton>
<UCard :ui="{ body: 'p-4 sm:p-5 space-y-3' }">
<h2 class="text-base font-medium">
操作
</h2>
<UButton type="submit" :loading="loading" block>
创建文章
</UButton>
<UButton to="/me/posts" variant="ghost" color="neutral" block>
取消并返回
</UButton>
</UCard>
</div>
</UForm>
</UContainer>

16
app/utils/post-slug.ts

@ -0,0 +1,16 @@
export const POST_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,98}[a-z0-9]$|^[a-z0-9]$/
export function normalizePostSlugCandidate(input: string): string {
const ascii = input
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/['"]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-')
const limited = ascii.slice(0, 100).replace(/-+$/g, '')
return limited
}

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

2
server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts

@ -48,9 +48,11 @@ export default defineEventHandler(async (event) => {
void notifyReplyCommentCreated({
postId: ctx.id,
postOwnerUserId: ctx.userId,
commentId: newCommentId,
parentId: parsed.parentId,
actorUserId: viewer?.id ?? null,
actorGuestEmail: viewer == null ? (parsed.guestEmail ?? null) : null,
replyBody: parsed.body,
});

2
server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts

@ -48,9 +48,11 @@ export default defineEventHandler(async (event) => {
void notifyReplyCommentCreated({
postId: ctx.id,
postOwnerUserId: ctx.userId,
commentId: newCommentId,
parentId: parsed.parentId,
actorUserId: viewer?.id ?? null,
actorGuestEmail: viewer == null ? (parsed.guestEmail ?? null) : null,
replyBody: parsed.body,
});

113
server/service/comment-notify/index.test.ts

@ -1,34 +1,35 @@
import { describe, expect, test } from "bun:test";
import { notifyReplyCommentCreated } from "./index";
import { notifyReplyCommentCreated, type NotifyDeps, type ReceiverTarget } from "./index";
function createDeps() {
const state = {
sendMailCalled: false,
};
const deps: NotifyDeps = {
getParentReceiver: async (): Promise<ReceiverTarget | null> => ({ kind: "user", userId: 2 }),
getGlobalConfig: async () => ({
enabled: true,
fromEmail: "noreply@example.com",
smtpHost: "smtp.example.com",
smtpPort: 465,
smtpSecure: true,
smtpUser: "smtp-user",
smtpPass: "smtp-pass",
}),
getReceiverNotifyEnabled: async () => true,
getReceiverProfile: async () => ({
email: "receiver@example.com",
username: "receiver",
nickname: "Receiver",
}),
sendMail: async () => {
state.sendMailCalled = true;
},
};
return {
state,
deps: {
getParentAuthorUserId: async () => 2,
getGlobalConfig: async () => ({
enabled: true,
fromEmail: "noreply@example.com",
smtpHost: "smtp.example.com",
smtpPort: 465,
smtpSecure: true,
smtpUser: "smtp-user",
smtpPass: "smtp-pass",
}),
getReceiverNotifyEnabled: async () => true,
getReceiverProfile: async () => ({
email: "receiver@example.com",
username: "receiver",
nickname: "Receiver",
}),
sendMail: async () => {
state.sendMailCalled = true;
},
},
deps,
};
}
@ -45,7 +46,10 @@ describe("notifyReplyCommentCreated", () => {
smtpPass: "smtp-pass",
});
await notifyReplyCommentCreated({ postId: 10, commentId: 200, parentId: 100, actorUserId: 1, replyBody: "hello" }, deps);
await notifyReplyCommentCreated(
{ postId: 10, postOwnerUserId: 2, commentId: 200, parentId: 100, actorUserId: 1, replyBody: "hello" },
deps,
);
expect(state.sendMailCalled).toBe(false);
});
@ -54,24 +58,33 @@ describe("notifyReplyCommentCreated", () => {
const { deps, state } = createDeps();
deps.getReceiverNotifyEnabled = async () => false;
await notifyReplyCommentCreated({ postId: 10, commentId: 201, parentId: 100, actorUserId: 1, replyBody: "hello" }, deps);
await notifyReplyCommentCreated(
{ postId: 10, postOwnerUserId: 2, commentId: 201, parentId: 100, actorUserId: 1, replyBody: "hello" },
deps,
);
expect(state.sendMailCalled).toBe(false);
});
test("无 parentId -> 不发送", async () => {
test("无 parentId -> 通知文章作者", async () => {
const { deps, state } = createDeps();
await notifyReplyCommentCreated({ postId: 10, commentId: 202, parentId: null, actorUserId: 1, replyBody: "hello" }, deps);
await notifyReplyCommentCreated(
{ postId: 10, postOwnerUserId: 2, commentId: 202, parentId: null, actorUserId: 1, replyBody: "hello" },
deps,
);
expect(state.sendMailCalled).toBe(false);
expect(state.sendMailCalled).toBe(true);
});
test("自通知 -> 不发送", async () => {
const { deps, state } = createDeps();
deps.getParentAuthorUserId = async () => 1;
deps.getParentReceiver = async () => ({ kind: "user", userId: 1 });
await notifyReplyCommentCreated({ postId: 10, commentId: 203, parentId: 100, actorUserId: 1, replyBody: "hello" }, deps);
await notifyReplyCommentCreated(
{ postId: 10, postOwnerUserId: 2, commentId: 203, parentId: 100, actorUserId: 1, replyBody: "hello" },
deps,
);
expect(state.sendMailCalled).toBe(false);
});
@ -83,7 +96,10 @@ describe("notifyReplyCommentCreated", () => {
};
await expect(
notifyReplyCommentCreated({ postId: 10, commentId: 204, parentId: 100, actorUserId: 1, replyBody: "hello" }, deps),
notifyReplyCommentCreated(
{ postId: 10, postOwnerUserId: 2, commentId: 204, parentId: 100, actorUserId: 1, replyBody: "hello" },
deps,
),
).resolves.toBeUndefined();
});
@ -95,7 +111,42 @@ describe("notifyReplyCommentCreated", () => {
nickname: "Receiver",
});
await notifyReplyCommentCreated({ postId: 10, commentId: 205, parentId: 100, actorUserId: 1, replyBody: "hello" }, deps);
await notifyReplyCommentCreated(
{ postId: 10, postOwnerUserId: 2, commentId: 205, parentId: 100, actorUserId: 1, replyBody: "hello" },
deps,
);
expect(state.sendMailCalled).toBe(false);
});
test("回复游客(有邮箱且非匿名)-> 发送邮件到游客邮箱", async () => {
const { deps, state } = createDeps();
deps.getParentReceiver = async () => ({ kind: "guest", email: "guest@example.com" });
await notifyReplyCommentCreated(
{ postId: 10, postOwnerUserId: 2, commentId: 206, parentId: 100, actorUserId: 1, replyBody: "hello" },
deps,
);
expect(state.sendMailCalled).toBe(true);
});
test("游客回复自己(同邮箱)-> 不发送", async () => {
const { deps, state } = createDeps();
deps.getParentReceiver = async () => ({ kind: "guest", email: "guest@example.com" });
await notifyReplyCommentCreated(
{
postId: 10,
postOwnerUserId: 2,
commentId: 207,
parentId: 100,
actorUserId: null,
actorGuestEmail: "guest@example.com",
replyBody: "hello",
},
deps,
);
expect(state.sendMailCalled).toBe(false);
});

80
server/service/comment-notify/index.ts

@ -20,8 +20,12 @@ type ReceiverProfile = {
nickname: string | null;
};
type NotifyDeps = {
getParentAuthorUserId: (parentId: number) => Promise<number | null>;
export type ReceiverTarget =
| { kind: "user"; userId: number }
| { kind: "guest"; email: string };
export type NotifyDeps = {
getParentReceiver: (parentId: number) => Promise<ReceiverTarget | null>;
getGlobalConfig: () => Promise<NotifyGlobalConfig>;
getReceiverNotifyEnabled: (userId: number) => Promise<boolean>;
getReceiverProfile: (userId: number) => Promise<ReceiverProfile | null>;
@ -66,7 +70,7 @@ function isSmtpConfigReady(config: NotifyGlobalConfig): boolean {
);
}
async function defaultGetParentAuthorUserId(parentId: number): Promise<number | null> {
async function defaultGetParentReceiver(parentId: number): Promise<ReceiverTarget | null> {
const { dbGlobal } = await import("drizzle-pkg/lib/db");
const { postComments } = await import("drizzle-pkg/lib/schema/content");
const { eq } = await import("drizzle-orm");
@ -74,12 +78,23 @@ async function defaultGetParentAuthorUserId(parentId: number): Promise<number |
const [parent] = await dbGlobal
.select({
authorUserId: postComments.authorUserId,
guestEmail: postComments.guestEmail,
guestIsAnonymous: postComments.guestIsAnonymous,
})
.from(postComments)
.where(eq(postComments.id, parentId))
.limit(1);
return parent?.authorUserId ?? null;
if (!parent) {
return null;
}
if (parent.authorUserId != null) {
return { kind: "user", userId: parent.authorUserId };
}
if (!parent.guestIsAnonymous && parent.guestEmail && hasValue(parent.guestEmail) && isValidEmail(parent.guestEmail)) {
return { kind: "guest", email: parent.guestEmail.trim() };
}
return null;
}
async function defaultGetGlobalConfig(): Promise<NotifyGlobalConfig> {
@ -150,7 +165,7 @@ async function defaultSendMail(input: { toEmail: string; fromEmail: string; repl
function getDefaultDeps(): NotifyDeps {
return {
getParentAuthorUserId: defaultGetParentAuthorUserId,
getParentReceiver: defaultGetParentReceiver,
getGlobalConfig: defaultGetGlobalConfig,
getReceiverNotifyEnabled: defaultGetReceiverNotifyEnabled,
getReceiverProfile: defaultGetReceiverProfile,
@ -161,25 +176,21 @@ function getDefaultDeps(): NotifyDeps {
export async function notifyReplyCommentCreated(
input: {
postId: number;
postOwnerUserId: number;
commentId: number;
parentId: number | null;
actorUserId: number | null;
actorGuestEmail?: string | null;
replyBody: string;
},
deps: NotifyDeps = getDefaultDeps(),
): Promise<void> {
let receiverUserId: number | null = null;
let receiverEmail: string | null = null;
try {
if (input.parentId == null) {
return;
}
receiverUserId = await deps.getParentAuthorUserId(input.parentId);
if (receiverUserId == null) {
return;
}
if (input.actorUserId != null && input.actorUserId === receiverUserId) {
const receiverTarget =
input.parentId == null ? ({ kind: "user", userId: input.postOwnerUserId } as const) : await deps.getParentReceiver(input.parentId);
if (receiverTarget == null) {
return;
}
@ -188,21 +199,35 @@ export async function notifyReplyCommentCreated(
return;
}
const receiverNotifyEnabled = await deps.getReceiverNotifyEnabled(receiverUserId);
if (!receiverNotifyEnabled) {
return;
}
const receiver = await deps.getReceiverProfile(receiverUserId);
if (!receiver?.email || !hasValue(receiver.email)) {
return;
}
if (!isValidEmail(receiver.email)) {
return;
if (receiverTarget.kind === "user") {
receiverUserId = receiverTarget.userId;
if (input.actorUserId != null && input.actorUserId === receiverUserId) {
return;
}
const receiverNotifyEnabled = await deps.getReceiverNotifyEnabled(receiverUserId);
if (!receiverNotifyEnabled) {
return;
}
const receiver = await deps.getReceiverProfile(receiverUserId);
if (!receiver?.email || !hasValue(receiver.email)) {
return;
}
if (!isValidEmail(receiver.email)) {
return;
}
receiverEmail = receiver.email.trim();
} else {
receiverEmail = receiverTarget.email.trim();
const actorGuestEmail = input.actorGuestEmail?.trim();
if (actorGuestEmail && receiverEmail.toLowerCase() === actorGuestEmail.toLowerCase()) {
return;
}
}
await deps.sendMail({
toEmail: receiver.email,
toEmail: receiverEmail,
fromEmail: globalConfig.fromEmail,
replyBody: input.replyBody,
});
@ -211,6 +236,7 @@ export async function notifyReplyCommentCreated(
postId: input.postId,
commentId: input.commentId,
receiverUserId,
receiverEmail,
reason: getReason(error),
stack: getStack(error),
});

Loading…
Cancel
Save