Browse Source

feat(global-config): add showDiscoverInHeaderForGuest option and update related components

- Introduced a new configuration option `showDiscoverInHeaderForGuest` to control the visibility of the "Discover" navigation link for guests.
- Updated the AppShell component to conditionally render the "Discover" link based on the user's login status and the new config option.
- Modified global configuration handling to include the new option, ensuring it is fetched and saved correctly.
- Enhanced middleware to allow guest access to the "Discover" route if the new config option is enabled.

These changes improve the user experience by providing guests with access to discover content while maintaining control over navigation visibility.
main
npmrun 3 weeks ago
parent
commit
f19412953f
  1. 17
      app/components/AppShell.vue
  2. 3
      app/composables/useGlobalConfig.ts
  3. 6
      app/middleware/auth.global.ts
  4. 12
      app/pages/me/admin/config/index.vue
  5. 14
      app/pages/me/posts/[id].vue
  6. 9
      app/pages/me/posts/new.vue
  7. 11
      app/utils/post-slug.ts
  8. BIN
      packages/drizzle-pkg/db.sqlite
  9. 6
      server/api/config/global.get.ts
  10. 8
      server/api/discover/users.get.ts
  11. 7
      server/service/config/registry.ts
  12. 1
      server/utils/auth-api-routes.ts

17
app/components/AppShell.vue

@ -12,7 +12,7 @@ withDefaults(
const route = useRoute() const route = useRoute()
const { loggedIn, user, clear, initialized, ensureClientMeSynced } = useAuthSession() const { loggedIn, user, clear, initialized, ensureClientMeSynced } = useAuthSession()
const { fetchData } = useClientApi() const { fetchData } = useClientApi()
const { allowRegister, siteName } = useGlobalConfig() const { allowRegister, siteName, showDiscoverInHeaderForGuest } = useGlobalConfig()
const logoutLoading = ref(false) const logoutLoading = ref(false)
@ -63,6 +63,7 @@ function navActive(to: string) {
const consoleNavActive = computed(() => navActive('/me')) const consoleNavActive = computed(() => navActive('/me'))
const showQuickCreate = computed(() => loggedIn.value && initialized.value) const showQuickCreate = computed(() => loggedIn.value && initialized.value)
const showGuestDiscoverNav = computed(() => !loggedIn.value && showDiscoverInHeaderForGuest.value)
const accountMenuItems = computed(() => { const accountMenuItems = computed(() => {
const u = user.value const u = user.value
@ -122,11 +123,12 @@ async function logout() {
</NuxtLink> </NuxtLink>
<nav <nav
v-if="loggedIn" v-if="loggedIn || showGuestDiscoverNav"
class="hidden min-w-0 flex-1 items-center gap-0.5 overflow-x-auto md:flex" class="hidden min-w-0 flex-1 items-center gap-0.5 overflow-x-auto md:flex"
aria-label="主导航" aria-label="主导航"
> >
<UButton <UButton
v-if="loggedIn"
:to="homeNav.to" :to="homeNav.to"
:icon="homeNav.icon" :icon="homeNav.icon"
:label="homeNav.label" :label="homeNav.label"
@ -139,6 +141,7 @@ async function logout() {
]" ]"
/> />
<UButton <UButton
v-if="loggedIn || showGuestDiscoverNav"
:to="discoverNav.to" :to="discoverNav.to"
:icon="discoverNav.icon" :icon="discoverNav.icon"
:label="discoverNav.label" :label="discoverNav.label"
@ -150,7 +153,7 @@ async function logout() {
navActive(discoverNav.to) ? 'bg-elevated text-highlighted' : 'text-muted', navActive(discoverNav.to) ? 'bg-elevated text-highlighted' : 'text-muted',
]" ]"
/> />
<UDropdownMenu :items="consoleDropdownItems" :content="{ align: 'start' }"> <UDropdownMenu v-if="loggedIn" :items="consoleDropdownItems" :content="{ align: 'start' }">
<UButton <UButton
color="neutral" color="neutral"
variant="ghost" variant="ghost"
@ -241,6 +244,14 @@ async function logout() {
<template v-else> <template v-else>
<UButton to="/login" color="neutral" variant="ghost" label="登录" /> <UButton to="/login" color="neutral" variant="ghost" label="登录" />
<UButton <UButton
v-if="showGuestDiscoverNav"
to="/discover"
color="neutral"
variant="ghost"
label="发现"
class="md:hidden"
/>
<UButton
v-if="allowRegister" v-if="allowRegister"
to="/register" to="/register"
color="neutral" color="neutral"

3
app/composables/useGlobalConfig.ts

@ -3,6 +3,7 @@ import { request, unwrapApiBody, type ApiResponse } from '../utils/http/factory'
export type GlobalConfig = { export type GlobalConfig = {
siteName: string siteName: string
allowRegister: boolean allowRegister: boolean
showDiscoverInHeaderForGuest: boolean
commentEmailNotifyEnabled: boolean commentEmailNotifyEnabled: boolean
commentMailFromEmail: string commentMailFromEmail: string
commentSmtpHost: string commentSmtpHost: string
@ -19,6 +20,7 @@ type GlobalConfigResult = {
const DEFAULT_GLOBAL_CONFIG: GlobalConfig = { const DEFAULT_GLOBAL_CONFIG: GlobalConfig = {
siteName: 'Person Panel', siteName: 'Person Panel',
allowRegister: true, allowRegister: true,
showDiscoverInHeaderForGuest: true,
commentEmailNotifyEnabled: false, commentEmailNotifyEnabled: false,
commentMailFromEmail: '', commentMailFromEmail: '',
commentSmtpHost: '', commentSmtpHost: '',
@ -53,6 +55,7 @@ export function useGlobalConfig() {
return n && n.length > 0 ? n : DEFAULT_GLOBAL_CONFIG.siteName return n && n.length > 0 ? n : DEFAULT_GLOBAL_CONFIG.siteName
}), }),
allowRegister: computed(() => config.value.allowRegister), allowRegister: computed(() => config.value.allowRegister),
showDiscoverInHeaderForGuest: computed(() => config.value.showDiscoverInHeaderForGuest),
pending, pending,
error, error,
refresh, refresh,

6
app/middleware/auth.global.ts

@ -5,6 +5,7 @@ import {
normalizeSafeRedirect, normalizeSafeRedirect,
} from "../utils/auth-routes"; } from "../utils/auth-routes";
import { useAuthSession } from "../composables/useAuthSession"; import { useAuthSession } from "../composables/useAuthSession";
import { useGlobalConfig } from "../composables/useGlobalConfig";
export default defineNuxtRouteMiddleware(async (to) => { export default defineNuxtRouteMiddleware(async (to) => {
if(to.path.startsWith("/__nuxt_error")) { if(to.path.startsWith("/__nuxt_error")) {
@ -24,8 +25,11 @@ export default defineNuxtRouteMiddleware(async (to) => {
const currentPath = to.path; const currentPath = to.path;
const currentFullPath = to.fullPath; const currentFullPath = to.fullPath;
const isLoggedIn = loggedIn.value; const isLoggedIn = loggedIn.value;
const { showDiscoverInHeaderForGuest } = useGlobalConfig();
const isDiscoverRoute = currentPath === "/discover" || currentPath.startsWith("/discover/");
const canGuestAccessDiscover = isDiscoverRoute && showDiscoverInHeaderForGuest.value;
if (!isLoggedIn && !isPublicRoute(currentPath)) { if (!isLoggedIn && !isPublicRoute(currentPath) && !canGuestAccessDiscover) {
return navigateTo({ return navigateTo({
path: "/login", path: "/login",
query: { redirect: currentFullPath }, query: { redirect: currentFullPath },

12
app/pages/me/admin/config/index.vue

@ -7,6 +7,7 @@ type GlobalConfigPayload = {
config: { config: {
siteName: string siteName: string
allowRegister: boolean allowRegister: boolean
showDiscoverInHeaderForGuest: boolean
mediaOrphanAutoSweepEnabled: boolean mediaOrphanAutoSweepEnabled: boolean
mediaOrphanAutoSweepIntervalMinutes: number mediaOrphanAutoSweepIntervalMinutes: number
commentEmailNotifyEnabled: boolean commentEmailNotifyEnabled: boolean
@ -30,6 +31,7 @@ const saving = ref(false)
const testingEmail = ref(false) const testingEmail = ref(false)
const siteName = ref('') const siteName = ref('')
const allowRegister = ref(true) const allowRegister = ref(true)
const showDiscoverInHeaderForGuest = ref(true)
const mediaOrphanAutoSweepEnabled = ref(false) const mediaOrphanAutoSweepEnabled = ref(false)
const mediaOrphanAutoSweepIntervalMinutes = ref(60) const mediaOrphanAutoSweepIntervalMinutes = ref(60)
const commentEmailNotifyEnabled = ref(false) const commentEmailNotifyEnabled = ref(false)
@ -72,6 +74,7 @@ async function load() {
const { config: cfg } = await fetchData<GlobalConfigPayload>('/api/config/global') const { config: cfg } = await fetchData<GlobalConfigPayload>('/api/config/global')
siteName.value = cfg.siteName siteName.value = cfg.siteName
allowRegister.value = cfg.allowRegister allowRegister.value = cfg.allowRegister
showDiscoverInHeaderForGuest.value = cfg.showDiscoverInHeaderForGuest
mediaOrphanAutoSweepEnabled.value = cfg.mediaOrphanAutoSweepEnabled mediaOrphanAutoSweepEnabled.value = cfg.mediaOrphanAutoSweepEnabled
mediaOrphanAutoSweepIntervalMinutes.value = cfg.mediaOrphanAutoSweepIntervalMinutes mediaOrphanAutoSweepIntervalMinutes.value = cfg.mediaOrphanAutoSweepIntervalMinutes
commentEmailNotifyEnabled.value = cfg.commentEmailNotifyEnabled commentEmailNotifyEnabled.value = cfg.commentEmailNotifyEnabled
@ -106,6 +109,7 @@ async function save() {
try { try {
await putKey('siteName', siteName.value.trim()) await putKey('siteName', siteName.value.trim())
await putKey('allowRegister', allowRegister.value) await putKey('allowRegister', allowRegister.value)
await putKey('showDiscoverInHeaderForGuest', showDiscoverInHeaderForGuest.value)
await putKey('mediaOrphanAutoSweepEnabled', mediaOrphanAutoSweepEnabled.value) await putKey('mediaOrphanAutoSweepEnabled', mediaOrphanAutoSweepEnabled.value)
await putKey('mediaOrphanAutoSweepIntervalMinutes', mediaOrphanAutoSweepIntervalMinutes.value) await putKey('mediaOrphanAutoSweepIntervalMinutes', mediaOrphanAutoSweepIntervalMinutes.value)
await putKey('commentEmailNotifyEnabled', commentEmailNotifyEnabled.value) await putKey('commentEmailNotifyEnabled', commentEmailNotifyEnabled.value)
@ -151,7 +155,7 @@ async function sendTestEmail() {
应用配置 应用配置
</h1> </h1>
<p class="mt-1 text-sm text-muted"> <p class="mt-1 text-sm text-muted">
管理全局设置站点名称注册开关媒体自动清扫与评论通知邮件 管理全局设置站点名称注册与导航开关媒体自动清扫与评论通知邮件
</p> </p>
</div> </div>
@ -175,6 +179,12 @@ async function sendTestEmail() {
<UCheckbox v-model="allowRegister" label="开启" /> <UCheckbox v-model="allowRegister" label="开启" />
</UFormField> </UFormField>
<UFormField <UFormField
label="未登录时在 Header 显示发现入口"
description="关闭后,游客将不再在顶栏看到“发现”导航入口。"
>
<UCheckbox v-model="showDiscoverInHeaderForGuest" label="显示发现入口" />
</UFormField>
<UFormField
label="媒体孤儿自动清扫" label="媒体孤儿自动清扫"
description="全站生效、仅删除已过宽限期且无引用的图片、建议先在「图片孤儿审查」确认。" description="全站生效、仅删除已过宽限期且无引用的图片、建议先在「图片孤儿审查」确认。"
> >

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

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAuthSession } from '../../../composables/useAuthSession' import { useAuthSession } from '../../../composables/useAuthSession'
import { normalizePostSlugCandidate } from '../../../utils/post-slug' import { generateRandomPostSlug } from '../../../utils/post-slug'
const route = useRoute() const route = useRoute()
const id = computed(() => route.params.id as string) const id = computed(() => route.params.id as string)
@ -18,6 +18,7 @@ const state = reactive({
}) })
const loading = ref(true) const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const persistedSlug = ref('')
const visibilityItems = [ const visibilityItems = [
{ label: '私密', value: 'private' }, { label: '私密', value: 'private' },
{ label: '公开', value: 'public' }, { label: '公开', value: 'public' },
@ -26,7 +27,8 @@ const visibilityItems = [
const bodyLength = computed(() => state.bodyMarkdown.trim().length) const bodyLength = computed(() => state.bodyMarkdown.trim().length)
const publicPostHref = computed(() => { const publicPostHref = computed(() => {
if (state.visibility !== 'public') { const hasUnsavedSlugChange = state.slug !== persistedSlug.value
if (state.visibility !== 'public' || hasUnsavedSlugChange) {
return `/me/posts/preview/${id.value}` return `/me/posts/preview/${id.value}`
} }
const ps = user.value?.publicSlug const ps = user.value?.publicSlug
@ -51,6 +53,7 @@ async function load(options?: { silent?: boolean }) {
visibility: p.visibility, visibility: p.visibility,
shareToken: p.shareToken ?? null, shareToken: p.shareToken ?? null,
}) })
persistedSlug.value = p.slug
} finally { } finally {
if (!silent) { if (!silent) {
loading.value = false loading.value = false
@ -72,13 +75,8 @@ usePageTitle(() => {
}) })
function generateSlugFromTitle() { function generateSlugFromTitle() {
if (!state.title.trim()) {
toast.add({ title: '请先填写标题', color: 'warning' })
return
}
const previous = state.slug const previous = state.slug
const normalized = normalizePostSlugCandidate(state.title) const normalized = generateRandomPostSlug()
state.slug = normalized state.slug = normalized

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

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { normalizePostSlugCandidate } from '../../../utils/post-slug' import { generateRandomPostSlug } from '../../../utils/post-slug'
usePageTitle('新建文章') usePageTitle('新建文章')
@ -23,13 +23,8 @@ const visibilityItems = [
const bodyLength = computed(() => state.bodyMarkdown.trim().length) const bodyLength = computed(() => state.bodyMarkdown.trim().length)
function generateSlugFromTitle() { function generateSlugFromTitle() {
if (!state.title.trim()) {
toast.add({ title: '请先填写标题', color: 'warning' })
return
}
const previous = state.slug const previous = state.slug
const normalized = normalizePostSlugCandidate(state.title) const normalized = generateRandomPostSlug()
state.slug = normalized state.slug = normalized

11
app/utils/post-slug.ts

@ -14,3 +14,14 @@ export function normalizePostSlugCandidate(input: string): string {
return limited return limited
} }
export function generateRandomPostSlug(): string {
// 输出如 post-a1b2c3d4e5,满足现有 slug 校验规则。
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'
let suffix = ''
for (let i = 0; i < 10; i += 1) {
const idx = Math.floor(Math.random() * alphabet.length)
suffix += alphabet[idx]
}
return `post-${suffix}`
}

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

6
server/api/config/global.get.ts

@ -1,7 +1,11 @@
import { KNOWN_CONFIG_KEYS } from "#server/service/config/registry"; import { KNOWN_CONFIG_KEYS } from "#server/service/config/registry";
import type { KnownConfigKey, KnownConfigValue } from "#server/service/config/registry"; import type { KnownConfigKey, KnownConfigValue } from "#server/service/config/registry";
const PUBLIC_GLOBAL_CONFIG_KEYS = ["siteName", "allowRegister"] as const satisfies readonly KnownConfigKey[]; const PUBLIC_GLOBAL_CONFIG_KEYS = [
"siteName",
"allowRegister",
"showDiscoverInHeaderForGuest",
] as const satisfies readonly KnownConfigKey[];
const SECRET_MASKED_GLOBAL_CONFIG_KEYS = new Set<KnownConfigKey>(["commentSmtpPass"]); const SECRET_MASKED_GLOBAL_CONFIG_KEYS = new Set<KnownConfigKey>(["commentSmtpPass"]);
export default defineWrappedResponseHandler(async (event) => { export default defineWrappedResponseHandler(async (event) => {

8
server/api/discover/users.get.ts

@ -1,7 +1,15 @@
import { getRequestIP } from "h3";
import { listDiscoverUsersPage } from "#server/service/discover"; import { listDiscoverUsersPage } from "#server/service/discover";
import { assertUnderRateLimit } from "#server/utils/simple-rate-limit";
export default defineWrappedResponseHandler(async (event) => { export default defineWrappedResponseHandler(async (event) => {
const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown";
assertUnderRateLimit(`discover-users:${ip}`, 120, 60_000);
const showDiscoverInHeaderForGuest = await event.context.config.getGlobal("showDiscoverInHeaderForGuest");
if (!showDiscoverInHeaderForGuest) {
await event.context.auth.requireUser(); await event.context.auth.requireUser();
}
const q = getQuery(event); const q = getQuery(event);
const payload = await listDiscoverUsersPage(q.page); const payload = await listDiscoverUsersPage(q.page);
return R.success(payload); return R.success(payload);

7
server/service/config/registry.ts

@ -39,6 +39,13 @@ const CONFIG_REGISTRY = {
defaultValue: true, defaultValue: true,
userOverridable: false, userOverridable: false,
}), }),
showDiscoverInHeaderForGuest: defineConfig<boolean>({
key: "showDiscoverInHeaderForGuest",
scope: "global",
valueType: "boolean",
defaultValue: true,
userOverridable: false,
}),
mediaOrphanAutoSweepEnabled: defineConfig<boolean>({ mediaOrphanAutoSweepEnabled: defineConfig<boolean>({
key: "mediaOrphanAutoSweepEnabled", key: "mediaOrphanAutoSweepEnabled",
scope: "global", scope: "global",

1
server/utils/auth-api-routes.ts

@ -10,6 +10,7 @@ const API_ALLOWLIST: RouteRule[] = [
/** 访客可读:无 Cookie 时不查库,用于客户端与 SSR 会话对齐 */ /** 访客可读:无 Cookie 时不查库,用于客户端与 SSR 会话对齐 */
{ path: "/api/auth/session", methods: ["GET"] }, { path: "/api/auth/session", methods: ["GET"] },
{ path: "/api/config/global", methods: ["GET"] }, { path: "/api/config/global", methods: ["GET"] },
{ path: "/api/discover/users", methods: ["GET"] },
]; ];
/** 允许访客发表评论的公开 POST(其余 /api/public/ 写操作仍拒绝) */ /** 允许访客发表评论的公开 POST(其余 /api/public/ 写操作仍拒绝) */

Loading…
Cancel
Save