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 { loggedIn, user, clear, initialized, ensureClientMeSynced } = useAuthSession()
const { fetchData } = useClientApi()
const { allowRegister, siteName } = useGlobalConfig()
const { allowRegister, siteName, showDiscoverInHeaderForGuest } = useGlobalConfig()
const logoutLoading = ref(false)
@ -63,6 +63,7 @@ function navActive(to: string) {
const consoleNavActive = computed(() => navActive('/me'))
const showQuickCreate = computed(() => loggedIn.value && initialized.value)
const showGuestDiscoverNav = computed(() => !loggedIn.value && showDiscoverInHeaderForGuest.value)
const accountMenuItems = computed(() => {
const u = user.value
@ -122,11 +123,12 @@ async function logout() {
</NuxtLink>
<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"
aria-label="主导航"
>
<UButton
v-if="loggedIn"
:to="homeNav.to"
:icon="homeNav.icon"
:label="homeNav.label"
@ -139,6 +141,7 @@ async function logout() {
]"
/>
<UButton
v-if="loggedIn || showGuestDiscoverNav"
:to="discoverNav.to"
:icon="discoverNav.icon"
:label="discoverNav.label"
@ -150,7 +153,7 @@ async function logout() {
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
color="neutral"
variant="ghost"
@ -241,6 +244,14 @@ async function logout() {
<template v-else>
<UButton to="/login" color="neutral" variant="ghost" label="登录" />
<UButton
v-if="showGuestDiscoverNav"
to="/discover"
color="neutral"
variant="ghost"
label="发现"
class="md:hidden"
/>
<UButton
v-if="allowRegister"
to="/register"
color="neutral"

3
app/composables/useGlobalConfig.ts

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

6
app/middleware/auth.global.ts

@ -5,6 +5,7 @@ import {
normalizeSafeRedirect,
} from "../utils/auth-routes";
import { useAuthSession } from "../composables/useAuthSession";
import { useGlobalConfig } from "../composables/useGlobalConfig";
export default defineNuxtRouteMiddleware(async (to) => {
if(to.path.startsWith("/__nuxt_error")) {
@ -24,8 +25,11 @@ export default defineNuxtRouteMiddleware(async (to) => {
const currentPath = to.path;
const currentFullPath = to.fullPath;
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({
path: "/login",
query: { redirect: currentFullPath },

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

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

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

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

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

@ -1,5 +1,5 @@
<script setup lang="ts">
import { normalizePostSlugCandidate } from '../../../utils/post-slug'
import { generateRandomPostSlug } from '../../../utils/post-slug'
usePageTitle('新建文章')
@ -23,13 +23,8 @@ const visibilityItems = [
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)
const normalized = generateRandomPostSlug()
state.slug = normalized

11
app/utils/post-slug.ts

@ -14,3 +14,14 @@ export function normalizePostSlugCandidate(input: string): string {
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 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"]);
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 { assertUnderRateLimit } from "#server/utils/simple-rate-limit";
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();
}
const q = getQuery(event);
const payload = await listDiscoverUsersPage(q.page);
return R.success(payload);

7
server/service/config/registry.ts

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

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

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

Loading…
Cancel
Save