feat: 可选社区上报与官方精选展柜

默认关闭,仪表盘一键开关;枢纽由运维配置收报,人工精选后展示于 /showcase。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-31 04:22:59 +08:00
parent 0abfb32194
commit 95c2154087
26 changed files with 1730 additions and 147 deletions

View File

@@ -32,6 +32,7 @@ const FavoritesPage = lazyWithRetry(() => import('./pages/FavoritesPage'));
const MessagesPage = lazyWithRetry(() => import('./pages/MessagesPage'));
const ProjectsPage = lazyWithRetry(() => import('./pages/ProjectsPage'));
const LinksPage = lazyWithRetry(() => import('./pages/LinksPage'));
const ShowcasePage = lazyWithRetry(() => import('./pages/ShowcasePage'));
const AdminDashboardPage = lazyWithRetry(() => import('./pages/admin/AdminDashboardPage'));
const AdminPostsPage = lazyWithRetry(() => import('./pages/admin/AdminPostsPage'));
const AdminCommentsPage = lazyWithRetry(() => import('./pages/admin/AdminCommentsPage'));
@@ -44,6 +45,7 @@ const AdminSitePageEditPage = lazyWithRetry(() => import('./pages/admin/AdminSit
const AdminLinksPage = lazyWithRetry(() => import('./pages/admin/AdminLinksPage'));
const SitePageView = lazyWithRetry(() => import('./pages/SitePageView'));
const AdminSettingsPage = lazyWithRetry(() => import('./pages/admin/AdminSettingsPage'));
const AdminCommunityPage = lazyWithRetry(() => import('./pages/admin/AdminCommunityPage'));
const NotFoundPage = lazyWithRetry(() => import('./pages/NotFoundPage'));
const router = createBrowserRouter(
@@ -61,6 +63,7 @@ const router = createBrowserRouter(
<Route path="pages/:id/edit" element={<Suspense fallback={<PageLoader />}><AdminSitePageEditPage /></Suspense>} />
<Route path="pages" element={<Suspense fallback={<PageLoader />}><AdminPagesPage /></Suspense>} />
<Route path="links" element={<Suspense fallback={<PageLoader />}><AdminLinksPage /></Suspense>} />
<Route path="community" element={<Suspense fallback={<PageLoader />}><AdminCommunityPage /></Suspense>} />
<Route path="posts" element={<Suspense fallback={<PageLoader />}><AdminPostsPage /></Suspense>} />
<Route path="comments" element={<Suspense fallback={<PageLoader />}><AdminCommentsPage /></Suspense>} />
<Route path="reports" element={<Suspense fallback={<PageLoader />}><AdminReportsPage /></Suspense>} />
@@ -82,6 +85,7 @@ const router = createBrowserRouter(
<Route path="/favorites" element={<FavoritesPage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/links" element={<LinksPage />} />
<Route path="/showcase" element={<Suspense fallback={<PageLoader />}><ShowcasePage /></Suspense>} />
<Route path="/messages" element={<MessagesPage />} />
<Route path="/page/:slug" element={<Suspense fallback={<PageLoader />}><SitePageView /></Suspense>} />
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage /></Suspense>} />

View File

@@ -1,4 +1,4 @@
import type { User, UserPublic, UserActivityStats, Board, PostItem, Comment, RecentComment, RecentUser, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, CommentRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, StorageConfig, MediaListResult, SiteBranding, RegisterConfig, PrivateMessage, MessageConversation, PostReport, ReportReason, ReportStatus, BadgeDef, PointLedger, CheckInStatus, LotteryStatus, SitePage, SitePageSummary, PollView, PostLotteryView, FriendLinkApply } from './types';
import type { User, UserPublic, UserActivityStats, Board, PostItem, Comment, RecentComment, RecentUser, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, CommentRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, StorageConfig, MediaListResult, SiteBranding, RegisterConfig, PrivateMessage, MessageConversation, PostReport, ReportReason, ReportStatus, BadgeDef, PointLedger, CheckInStatus, LotteryStatus, SitePage, SitePageSummary, PollView, PostLotteryView, FriendLinkApply, CommunityConfig, CommunityInstance, CommunityShowcaseItem } from './types';
const BASE = '';
@@ -63,6 +63,19 @@ export const api = {
// 管理后台 API
adminDashboard: () => request<AdminDashboard>('/api/admin/dashboard'),
adminSettings: () => request<AdminSettings>('/api/admin/settings'),
adminUpdateCommunitySettings: (body: CommunityConfig) =>
request<{ message: string; community: CommunityConfig; heartbeat_error?: string }>('/api/admin/settings/community', {
method: 'PUT', body: JSON.stringify(body),
}),
adminCommunityInstances: () =>
request<{ hub_enabled: boolean; instances: CommunityInstance[] }>('/api/admin/community/instances'),
adminFeatureCommunityInstance: (instanceId: string, body: { featured: boolean; featured_note?: string }) =>
request<{ message: string; instance: CommunityInstance }>(
`/api/admin/community/instances/${encodeURIComponent(instanceId)}/feature`,
{ method: 'PUT', body: JSON.stringify(body) },
),
communityShowcase: () =>
request<{ items: CommunityShowcaseItem[] }>('/api/community/showcase'),
adminPosts: (params: { page?: number; keyword?: string; status?: string }) => {
const q = new URLSearchParams();
if (params.page) q.set('page', String(params.page));

View File

@@ -376,10 +376,45 @@ export interface AdminSettings {
gitea?: GiteaSyncConfig;
storage?: StorageConfig;
branding?: SiteBranding;
community?: CommunityConfig;
filter_words: string;
filter_word_count: number;
}
/** 社区上报配置hub_url / site_url 只读) */
export interface CommunityConfig {
report_enabled: boolean;
/** 只读:是否作为社区枢纽 */
hub_enabled: boolean;
/** 只读:固定为官方演示站 */
hub_url: string;
/** 只读:服务端自动推断的本站地址 */
site_url: string;
instance_id: string;
}
export interface CommunityInstance {
instance_id: string;
site_url: string;
site_name: string;
version: string;
users: number;
posts: number;
first_seen_at: string;
last_seen_at: string;
online: boolean;
featured: boolean;
featured_note: string;
}
/** 公开展柜条目 */
export interface CommunityShowcaseItem {
site_url: string;
site_name: string;
version: string;
featured_note?: string;
}
export interface StorageConfig {
type: 'local' | 's3';
endpoint: string;

View File

@@ -0,0 +1,85 @@
import { useEffect, useState } from 'react';
import { Heart } from 'lucide-react';
import { notify } from '@/lib/notify';
import { api } from '../../api/client';
import type { CommunityConfig } from '../../api/types';
const EMPTY_COMMUNITY: CommunityConfig = {
report_enabled: false,
hub_enabled: false,
hub_url: 'https://bbs.iioio.com',
site_url: '',
instance_id: '',
};
/** 仪表盘页脚:自愿社区上报开关(默认关,即时保存) */
export default function CommunitySupportStrip() {
const [community, setCommunity] = useState<CommunityConfig>(EMPTY_COMMUNITY);
const [saving, setSaving] = useState(false);
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
api.adminSettings()
.then((s) => {
if (!cancelled) {
setCommunity({ ...EMPTY_COMMUNITY, ...(s.community ?? {}) });
setReady(true);
}
})
.catch(() => {
if (!cancelled) setReady(true);
});
return () => { cancelled = true; };
}, []);
const handleToggle = async () => {
if (saving || !ready) return;
const next = !community.report_enabled;
const prev = community;
setCommunity((c) => ({ ...c, report_enabled: next }));
setSaving(true);
try {
const r = await api.adminUpdateCommunitySettings({
...EMPTY_COMMUNITY,
report_enabled: next,
});
setCommunity({ ...EMPTY_COMMUNITY, ...r.community });
if (r.heartbeat_error) {
notify.warning(`${r.message}${r.heartbeat_error}`);
} else {
notify.success(next ? '已开启社区上报' : '已关闭社区上报');
}
} catch (e: unknown) {
setCommunity(prev);
notify.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
};
return (
<div className="admin-community-support-strip" role="group" aria-label="支持姜十三开源">
<div className="admin-community-support-strip-main">
<Heart size={16} className="admin-community-support-strip-icon" aria-hidden />
<div className="admin-community-support-strip-copy">
<strong></strong>
<span>
bbs.iioio.com
</span>
</div>
</div>
<button
type="button"
role="switch"
aria-checked={community.report_enabled}
aria-busy={saving}
disabled={saving || !ready}
className={`admin-settings-switch${community.report_enabled ? ' is-on' : ''}`}
onClick={() => void handleToggle()}
>
<span className="admin-settings-switch-ui" aria-hidden />
</button>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import {
LayoutDashboard, FolderKanban, FileText, MessageSquare, Flag, Users, Images, Settings, ArrowLeft, Moon, Sun, Menu, X, Award, Link2, BookOpen,
LayoutDashboard, FolderKanban, FileText, MessageSquare, Flag, Users, Images, Settings, ArrowLeft, Moon, Sun, Menu, X, Award, Link2, BookOpen, Globe2,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { useAuth } from '../hooks/useAuth';
@@ -23,6 +23,8 @@ type NavItem = {
label: string;
icon: typeof LayoutDashboard;
badgeKey?: BadgeKey;
/** 仅社区枢纽开启时显示 */
hubOnly?: boolean;
};
type NavGroup = {
@@ -54,6 +56,7 @@ const NAV_GROUPS: NavGroup[] = [
{ to: '/admin/users', label: '用户管理', icon: Users },
{ to: '/admin/badges', label: '徽章管理', icon: Award },
{ to: '/admin/links', label: '友情链接', icon: Link2, badgeKey: 'links' },
{ to: '/admin/community', label: '公网实例', icon: Globe2, hubOnly: true },
],
},
{
@@ -86,6 +89,7 @@ export default function AdminLayout() {
const isNarrow = useMediaQuery('(max-width: 768px)');
const [navOpen, setNavOpen] = useState(false);
const [pending, setPending] = useState<PendingCounts>({ posts: 0, comments: 0, reports: 0, links: 0 });
const [communityHub, setCommunityHub] = useState(false);
const nav = useNavigate();
const location = useLocation();
const drawerRef = useRef<HTMLElement>(null);
@@ -107,6 +111,13 @@ export default function AdminLayout() {
.catch(() => {});
}, []);
useEffect(() => {
if (loading || !user || user.role !== 'admin') return;
api.adminSettings()
.then(s => setCommunityHub(!!s.community?.hub_enabled))
.catch(() => setCommunityHub(false));
}, [loading, user]);
useEffect(() => {
if (loading || !user || user.role !== 'admin') return;
refreshPending();
@@ -156,7 +167,7 @@ export default function AdminLayout() {
NAV_GROUPS.map(group => (
<div key={group.label} className="admin-nav-group">
<div className="admin-nav-group-label">{group.label}</div>
{group.items.map(({ to, label, icon: Icon, badgeKey }) => {
{group.items.filter(item => !item.hubOnly || communityHub).map(({ to, label, icon: Icon, badgeKey }) => {
const badge = badgeKey ? formatNavBadge(pending[badgeKey]) : null;
return (
<NavLink

View File

@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react';
import { ExternalLink, Globe2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { api } from '../api/client';
import type { CommunityShowcaseItem } from '../api/types';
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
import { getCachedSiteBranding, useSiteBranding } from '../hooks/useSiteBranding';
import { InFlowSiteFooter } from '../components/SiteFooter';
/** 官方精选的公网部署展柜(只读;仅人工精选条目) */
export default function ShowcasePage() {
const { branding } = useSiteBranding();
const [items, setItems] = useState<CommunityShowcaseItem[]>([]);
const [loading, setLoading] = useState(true);
usePageSEO({
title: '开源部署展柜',
description: `${branding.name} 精选的姜十三论坛公网部署`,
keywords: joinSEOKeywords('开源', '部署', '展柜', getCachedSiteBranding().keywords),
canonicalPath: '/showcase',
});
useEffect(() => {
let cancelled = false;
api.communityShowcase()
.then((r) => {
if (!cancelled) setItems(Array.isArray(r.items) ? r.items : []);
})
.catch(() => {
if (!cancelled) setItems([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
return (
<div className="showcase-page">
<header className="showcase-head">
<div className="showcase-head-mark" aria-hidden>
<Globe2 size={22} />
</div>
<div>
<h1 className="showcase-title"></h1>
<p className="showcase-desc">
</p>
</div>
</header>
{loading ? (
<div className="flex justify-center py-16"><Spinner size="lg" /></div>
) : items.length === 0 ? (
<p className="showcase-empty"></p>
) : (
<ul className="showcase-list">
{items.map((item) => (
<li key={item.site_url} className="showcase-item">
<a
href={item.site_url}
target="_blank"
rel="noopener noreferrer"
className="showcase-item-link"
>
<span className="showcase-item-name">{item.site_name || '未命名站点'}</span>
<span className="showcase-item-url">
{item.site_url}
<ExternalLink size={12} aria-hidden />
</span>
{(item.featured_note || item.version) && (
<span className="showcase-item-meta">
{item.featured_note || null}
{item.featured_note && item.version ? ' · ' : null}
{item.version ? `v${item.version}` : null}
</span>
)}
</a>
</li>
))}
</ul>
)}
<InFlowSiteFooter />
</div>
);
}

View File

@@ -0,0 +1,159 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Globe2, ExternalLink, Star } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { notify } from '@/lib/notify';
import { api } from '../../api/client';
import type { CommunityInstance } from '../../api/types';
import { useAdminGuard } from '../../layouts/AdminLayout';
import { formatTime } from '../../utils/content';
import { cn } from '@/lib/utils';
export default function AdminCommunityPage() {
const { ready } = useAdminGuard();
const [hubEnabled, setHubEnabled] = useState(false);
const [list, setList] = useState<CommunityInstance[]>([]);
const [loading, setLoading] = useState(true);
const [featuringId, setFeaturingId] = useState<string | null>(null);
useEffect(() => {
if (!ready) return;
setLoading(true);
api.adminCommunityInstances()
.then((r) => {
setHubEnabled(!!r.hub_enabled);
setList(Array.isArray(r.instances) ? r.instances : []);
})
.catch(() => {
setList([]);
})
.finally(() => setLoading(false));
}, [ready]);
const handleToggleFeatured = async (row: CommunityInstance) => {
if (featuringId) return;
setFeaturingId(row.instance_id);
try {
const r = await api.adminFeatureCommunityInstance(row.instance_id, {
featured: !row.featured,
featured_note: row.featured_note || '',
});
setList((prev) => prev.map((item) => (
item.instance_id === row.instance_id ? { ...item, ...r.instance } : item
)));
notify.success(r.message);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
} finally {
setFeaturingId(null);
}
};
if (!ready || loading) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
return (
<div className="admin-page">
<div className="admin-page-head">
<div>
<h1 className="admin-page-title">
<Globe2 size={22} aria-hidden />
</h1>
<p className="admin-page-desc">
{' '}
<Link to="/showcase" className="admin-inline-link" target="_blank" rel="noopener noreferrer"></Link>
</p>
</div>
</div>
{!hubEnabled && (
<div className="admin-card admin-settings-card" style={{ marginBottom: 16 }}>
<div className="admin-card-body">
<p>
<code>app.ini</code> <code>[community] hub = true</code>
{' '} <code>JIANG13_COMMUNITY_HUB=1</code>
</p>
</div>
</div>
)}
<div className="admin-card">
<div className="admin-card-head">
<span></span>
<span className="admin-settings-card-badge">{list.length} </span>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{list.length === 0 ? (
<p className="admin-empty" style={{ padding: 24 }}></p>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{list.map((row) => (
<tr key={row.instance_id}>
<td>
<Badge variant={row.online ? 'default' : 'secondary'}>
{row.online ? '在线' : '离线'}
</Badge>
</td>
<td>
<div className="admin-community-site">
<strong>
{row.featured && <Star size={12} className="admin-community-star" aria-hidden />}
{row.site_name || '未命名站点'}
</strong>
<a
href={row.site_url}
target="_blank"
rel="noopener noreferrer"
className={cn('admin-inline-link', 'admin-community-url')}
>
{row.site_url}
<ExternalLink size={12} aria-hidden />
</a>
</div>
</td>
<td><code>{row.version || '—'}</code></td>
<td>{row.users}</td>
<td>{row.posts}</td>
<td title={row.last_seen_at}>{formatTime(row.last_seen_at)}</td>
<td>
<Button
variant="outline"
size="sm"
loading={featuringId === row.instance_id}
disabled={!!featuringId}
onClick={() => void handleToggleFeatured(row)}
>
{row.featured ? '取消精选' : '精选'}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { AdminDashboard } from '../../api/types';
import { cn } from '@/lib/utils';
import CommunitySupportStrip from '../../components/admin/CommunitySupportStrip';
export default function AdminDashboardPage() {
const nav = useNavigate();
@@ -127,6 +128,8 @@ export default function AdminDashboardPage() {
</div>
</section>
<CommunitySupportStrip />
<div className="admin-card">
<div className="admin-card-head">
<span></span>

View File

@@ -14006,6 +14006,201 @@ button.post-poll__option,
}
.admin-inline-link:hover { text-decoration: underline; }
.admin-community-support-strip {
display: inline-flex;
width: fit-content;
max-width: 100%;
align-items: center;
justify-content: flex-start;
gap: 20px;
margin: 16px 0;
padding: 12px 14px;
border: 1px solid color-mix(in srgb, var(--j13-green) 22%, var(--j13-border-light));
border-left: 3px solid var(--j13-green);
border-radius: 8px;
background: color-mix(in srgb, var(--j13-green) 5%, var(--j13-bg-surface));
}
.admin-community-support-strip-main {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 0;
}
.admin-community-support-strip-icon {
flex-shrink: 0;
margin-top: 2px;
color: var(--j13-green);
}
.admin-community-support-strip-copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
max-width: 28rem;
}
.admin-community-support-strip-copy strong {
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
}
.admin-community-support-strip-copy span {
font-size: 12px;
line-height: 1.45;
color: var(--color-text-2);
}
.admin-community-support-strip .admin-settings-switch {
flex-shrink: 0;
}
.admin-community-support-strip .admin-settings-switch:disabled {
opacity: 0.6;
cursor: wait;
}
@media (max-width: 640px) {
.admin-community-support-strip {
display: flex;
width: 100%;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.admin-community-support-strip-copy {
max-width: none;
}
}
.admin-community-site {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.admin-community-site strong {
display: inline-flex;
align-items: center;
gap: 4px;
}
.admin-community-star {
color: var(--j13-green);
flex-shrink: 0;
}
.admin-community-url {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
word-break: break-all;
}
.showcase-page {
max-width: 720px;
margin: 0 auto;
padding: 28px 16px 48px;
}
.showcase-head {
display: flex;
gap: 14px;
align-items: flex-start;
margin-bottom: 24px;
}
.showcase-head-mark {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
color: var(--j13-green);
background: color-mix(in srgb, var(--j13-green) 10%, var(--j13-bg-surface));
border: 1px solid color-mix(in srgb, var(--j13-green) 20%, var(--j13-border-light));
flex-shrink: 0;
}
.showcase-title {
margin: 0;
font-size: 1.35rem;
font-weight: 650;
color: var(--color-text-1);
}
.showcase-desc {
margin: 6px 0 0;
font-size: 13px;
line-height: 1.5;
color: var(--color-text-2);
}
.showcase-empty {
margin: 0;
padding: 32px 0;
text-align: center;
font-size: 14px;
color: var(--color-text-2);
}
.showcase-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.showcase-item {
border: 1px solid var(--j13-border-light);
border-radius: 10px;
background: var(--j13-bg-surface);
}
.showcase-item-link {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
text-decoration: none;
color: inherit;
border-radius: 10px;
transition: background 0.15s ease;
}
.showcase-item-link:hover {
background: color-mix(in srgb, var(--j13-green) 6%, var(--j13-bg-surface));
}
.showcase-item-name {
font-size: 15px;
font-weight: 600;
color: var(--color-text-1);
}
.showcase-item-url {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--j13-green);
word-break: break-all;
}
.showcase-item-meta {
font-size: 12px;
color: var(--color-text-2);
}
.admin-links-logo-thumb {
flex-shrink: 0;
width: 40px;