支持板块图标与主题色自定义,并优化列表加载与未保存离开提示。

新增后端图标校验与色槽规范化,前端展示 BoardBadge/骨架屏,发帖与板块管理页增加未保存确认。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-28 17:13:32 +08:00
parent e038d4f9e9
commit 55c256654b
63 changed files with 1966 additions and 714 deletions

View File

@@ -1,5 +1,11 @@
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import {
createBrowserRouter,
createRoutesFromElements,
RouterProvider,
Route,
Navigate,
} from 'react-router-dom';
import './styles/global.css';
import { AuthProvider } from './hooks/useAuth';
import { ThemeProvider } from './hooks/useTheme';
@@ -23,35 +29,39 @@ const AdminCommentsPage = lazy(() => import('./pages/admin/AdminCommentsPage'));
const AdminUsersPage = lazy(() => import('./pages/admin/AdminUsersPage'));
const AdminSettingsPage = lazy(() => import('./pages/admin/AdminSettingsPage'));
const router = createBrowserRouter(
createRoutesFromElements(
<>
<Route path="/login" element={<Suspense fallback={<PageLoader />}><LoginPage /></Suspense>} />
<Route path="/register" element={<Suspense fallback={<PageLoader />}><RegisterPage /></Suspense>} />
<Route path="/boards" element={<Navigate to="/admin/boards" replace />} />
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/dashboard" replace />} />
<Route path="dashboard" element={<Suspense fallback={<PageLoader />}><AdminDashboardPage /></Suspense>} />
<Route path="boards" element={<Suspense fallback={<PageLoader />}><BoardsManagePage /></Suspense>} />
<Route path="posts" element={<Suspense fallback={<PageLoader />}><AdminPostsPage /></Suspense>} />
<Route path="comments" element={<Suspense fallback={<PageLoader />}><AdminCommentsPage /></Suspense>} />
<Route path="users" element={<Suspense fallback={<PageLoader />}><AdminUsersPage /></Suspense>} />
<Route path="settings" element={<Suspense fallback={<PageLoader />}><AdminSettingsPage /></Suspense>} />
</Route>
<Route element={<MainLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/post/:id" element={<PostDetailPage />} />
<Route path="/post/:id/edit" element={<ComposePage />} />
<Route path="/compose" element={<ComposePage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/favorites" element={<FavoritesPage />} />
</Route>
</>,
),
);
export default function App() {
return (
<ThemeProvider>
<AuthProvider>
<ErrorBoundary>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Suspense fallback={<PageLoader />}><LoginPage /></Suspense>} />
<Route path="/register" element={<Suspense fallback={<PageLoader />}><RegisterPage /></Suspense>} />
<Route path="/boards" element={<Navigate to="/admin/boards" replace />} />
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/dashboard" replace />} />
<Route path="dashboard" element={<Suspense fallback={<PageLoader />}><AdminDashboardPage /></Suspense>} />
<Route path="boards" element={<Suspense fallback={<PageLoader />}><BoardsManagePage /></Suspense>} />
<Route path="posts" element={<Suspense fallback={<PageLoader />}><AdminPostsPage /></Suspense>} />
<Route path="comments" element={<Suspense fallback={<PageLoader />}><AdminCommentsPage /></Suspense>} />
<Route path="users" element={<Suspense fallback={<PageLoader />}><AdminUsersPage /></Suspense>} />
<Route path="settings" element={<Suspense fallback={<PageLoader />}><AdminSettingsPage /></Suspense>} />
</Route>
<Route element={<MainLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/post/:id" element={<PostDetailPage />} />
<Route path="/post/:id/edit" element={<ComposePage />} />
<Route path="/compose" element={<ComposePage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/favorites" element={<FavoritesPage />} />
</Route>
</Routes>
</BrowserRouter>
<RouterProvider router={router} />
<Toaster />
</ErrorBoundary>
</AuthProvider>

View File

@@ -43,9 +43,9 @@ export const api = {
online: () => request<OnlineStats>('/api/online'),
presence: () => request<Pick<OnlineStats, 'count' | 'members' | 'guests'>>('/api/presence', { method: 'POST' }),
favorites: () => request<{ favorites: unknown[]; total: number }>('/api/favorites'),
createBoard: (body: { name: string; description: string; sort_order: number }) =>
createBoard: (body: { name: string; description: string; sort_order: number; icon?: string; color_index?: number }) =>
request<{ board: Board }>('/api/admin/boards', { method: 'POST', body: JSON.stringify(body) }),
updateBoard: (id: number, body: { name: string; description: string; sort_order: number }) =>
updateBoard: (id: number, body: { name: string; description: string; sort_order: number; icon?: string; color_index?: number }) =>
request<{ board: Board }>(`/api/admin/boards/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
deleteBoard: (id: number) => request(`/api/admin/boards/${id}`, { method: 'DELETE' }),
// 管理后台 API

View File

@@ -12,6 +12,8 @@ export interface Board {
id: number;
name: string;
description: string;
icon?: string;
color_index?: number;
sort_order: number;
post_count?: number;
}

View File

@@ -0,0 +1,93 @@
import { cn } from '@/lib/utils';
import {
BOARD_ICON_OPTIONS,
BOARD_PALETTE_SIZE,
getBoardIcon,
getBoardThemeIndex,
} from '../utils/boardTheme';
import type { Board } from '../api/types';
interface IconPickerProps {
value: string;
onChange: (key: string) => void;
board: Pick<Board, 'id' | 'color_index'>;
}
interface ColorPickerProps {
value: number;
onChange: (index: number) => void;
boardId: number;
}
/** 板块图标选择器 */
export function BoardIconPicker({ value, onChange, board }: IconPickerProps) {
const previewBoard = { id: board.id, icon: value || undefined, color_index: board.color_index };
const PreviewIcon = getBoardIcon(previewBoard);
const themeIdx = getBoardThemeIndex(previewBoard);
return (
<div className="board-appearance-picker">
<div className="board-appearance-picker__preview">
<span className={cn('board-appearance-picker__preview-icon', `sidebar-board-icon--${themeIdx}`)}>
<PreviewIcon aria-hidden />
</span>
<span className="board-appearance-picker__preview-hint"></span>
</div>
<div className="board-icon-grid" role="listbox" aria-label="选择板块图标">
<button
type="button"
role="option"
aria-selected={!value}
className={cn('board-icon-option', !value && 'board-icon-option--active')}
title="自动(按板块 ID"
onClick={() => onChange('')}
>
<span className="board-icon-option__auto">A</span>
</button>
{BOARD_ICON_OPTIONS.map(({ key, label, Icon }) => (
<button
key={key}
type="button"
role="option"
aria-selected={value === key}
aria-label={label}
title={label}
className={cn('board-icon-option', value === key && 'board-icon-option--active')}
onClick={() => onChange(key)}
>
<Icon aria-hidden />
</button>
))}
</div>
</div>
);
}
/** 板块色标选择器(-1 为自动) */
export function BoardColorPicker({ value, onChange, boardId }: ColorPickerProps) {
return (
<div className="board-color-grid" role="listbox" aria-label="选择板块色标">
<button
type="button"
role="option"
aria-selected={value < 0}
className={cn('board-color-option board-color-option--auto', value < 0 && 'board-color-option--active')}
title="自动(按板块 ID"
onClick={() => onChange(-1)}
>
A
</button>
{Array.from({ length: BOARD_PALETTE_SIZE }, (_, i) => (
<button
key={i}
type="button"
role="option"
aria-selected={value === i}
className={cn('board-color-option', `board-color-option--${i}`, value === i && 'board-color-option--active')}
title={`色标 ${i + 1}`}
onClick={() => onChange(i)}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { cn } from '@/lib/utils';
import { getBoardThemeIndex } from '../utils/boardTheme';
import type { Board } from '../api/types';
interface Props {
board: Pick<Board, 'id' | 'name' | 'color_index'>;
className?: string;
}
/** 按板块配置或 id 映射不同色标的 Badge */
export default function BoardBadge({ board, className }: Props) {
const idx = getBoardThemeIndex(board);
return (
<span className={cn('board-badge', `board-badge--${idx}`, className)}>
{board.name}
</span>
);
}

View File

@@ -0,0 +1,14 @@
import { getBoardIcon } from '../utils/boardTheme';
import type { Board } from '../api/types';
/** 渲染板块 Lucide 图标 */
export default function BoardIconDisplay({
board,
className,
}: {
board: Pick<Board, 'id' | 'icon' | 'color_index'>;
className?: string;
}) {
const Icon = getBoardIcon(board);
return <Icon className={className} aria-hidden />;
}

View File

@@ -1,3 +1,4 @@
import { Users, FileText, LayoutGrid } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import type { Board, ForumStats } from '../api/types';
@@ -24,13 +25,20 @@ export default function FeedHeader({ boardId, keyword, boards, stats, postTotal
<div className="feed-head__title">
<h2 title={boardHint || undefined}>{title}</h2>
{!keyword && stats && (
<span className="feed-head__meta">
<strong>{stats.users}</strong>
<span className="feed-head__dot" aria-hidden>·</span>
<strong>{stats.posts}</strong>
<span className="feed-head__dot" aria-hidden>·</span>
<strong>{stats.boards}</strong>
</span>
<div className="feed-head__stats">
<span className="feed-stat-chip">
<Users aria-hidden />
<strong>{stats.users}</strong>
</span>
<span className="feed-stat-chip">
<FileText aria-hidden />
<strong>{stats.posts}</strong>
</span>
<span className="feed-stat-chip">
<LayoutGrid aria-hidden />
<strong>{stats.boards}</strong>
</span>
</div>
)}
</div>
{keyword && (

View File

@@ -1,4 +1,5 @@
import { Badge } from '@/components/ui/badge';
import { MessageCircle, ThumbsUp } from 'lucide-react';
import BoardBadge from '@/components/BoardBadge';
import PinnedIcon from '@/components/PinnedIcon';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
@@ -17,6 +18,8 @@ export default function PostListItem({ post, sort = 'latest', onClick }: Props)
? `${formatTime(post.last_reply_at)} 回复`
: '暂无回复')
: formatTime(post.created_at);
const commentCount = post.comment_count ?? 0;
const likeCount = post.like_count ?? 0;
return (
<div className="post-row" onClick={onClick}>
@@ -29,14 +32,20 @@ export default function PostListItem({ post, sort = 'latest', onClick }: Props)
{post.title}
</div>
<div className="post-meta">
{post.board && <Badge variant="green">{post.board.name}</Badge>}
{post.board && <BoardBadge board={post.board} />}
<span>{post.user?.nickname || '匿名'}</span>
<span>{timeLabel}</span>
</div>
</div>
<div className="post-stats">
<span>💬 {post.comment_count ?? 0}</span>
<span>👍 {post.like_count ?? 0}</span>
<span className={`post-stat${commentCount === 0 ? ' post-stat--zero' : ''}`}>
<MessageCircle aria-hidden />
{commentCount}
</span>
<span className={`post-stat${likeCount === 0 ? ' post-stat--zero' : ''}`}>
<ThumbsUp aria-hidden />
{likeCount}
</span>
</div>
</div>
);

View File

@@ -0,0 +1,30 @@
import { Skeleton } from '@/components/ui/skeleton';
interface Props {
count?: number;
}
/** 帖子列表加载骨架屏 */
export default function PostListSkeleton({ count = 8 }: Props) {
return (
<div className="post-list-skeleton" aria-busy="true" aria-label="加载中">
{Array.from({ length: count }, (_, i) => (
<div key={i} className="post-row post-row--skeleton">
<Skeleton className="skeleton--avatar" />
<div className="post-body">
<Skeleton className="skeleton--title" style={{ width: `${55 + (i % 4) * 10}%` }} />
<div className="skeleton-meta-row">
<Skeleton className="skeleton--badge" />
<Skeleton className="skeleton--meta" />
<Skeleton className="skeleton--meta skeleton--meta-short" />
</div>
</div>
<div className="post-stats">
<Skeleton className="skeleton--stat" />
<Skeleton className="skeleton--stat" />
</div>
</div>
))}
</div>
);
}

View File

@@ -1,3 +1,4 @@
import { Flame, Megaphone, Users } from 'lucide-react';
import type { PostItem, Notification, OnlineStats } from '../api/types';
interface Props {
@@ -7,45 +8,61 @@ interface Props {
onPostClick: (id: number) => void;
}
function hotRankClass(index: number): string {
if (index === 0) return 'widget-rank widget-rank--1';
if (index === 1) return 'widget-rank widget-rank--2';
if (index === 2) return 'widget-rank widget-rank--3';
return 'widget-rank';
}
export default function RightPanel({ hot, notifications, online, onPostClick }: Props) {
const hotList = hot?.slice(0, 8) ?? [];
const noticeList = notifications?.slice(0, 6) ?? [];
const members = online?.users ?? [];
return (
<>
<div className="aside-panel-inner">
<div className="widget-card">
<div className="widget-card-head">🔥 </div>
<div className="widget-card-head">
<Flame className="widget-card-icon widget-card-icon--hot" aria-hidden />
</div>
<div className="widget-card-body">
{hotList.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--color-text-3)', padding: '8px 0' }}></div>
<div className="widget-empty"></div>
) : hotList.map((item, i) => (
<div key={item.id} className="widget-item" onClick={() => onPostClick(item.id)}>
<span style={{ color: i < 3 ? '#e74c3c' : 'var(--color-text-3)', fontWeight: 600, minWidth: 18 }}>{i + 1}</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.title}</span>
<span className={hotRankClass(i)}>{i + 1}</span>
<span className="widget-item-title">{item.title}</span>
</div>
))}
</div>
</div>
<div className="widget-card">
<div className="widget-card-head">📢 </div>
<div className="widget-card-head">
<Megaphone className="widget-card-icon widget-card-icon--notice" aria-hidden />
</div>
<div className="widget-card-body">
{noticeList.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--color-text-3)', padding: '8px 0' }}></div>
<div className="widget-empty"></div>
) : noticeList.map(item => (
<div key={item.id} className="widget-item" onClick={() => onPostClick(item.id)}>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.title}</span>
<span style={{ fontSize: 11, color: 'var(--color-text-4)', flexShrink: 0 }}>{item.created_at}</span>
<div key={item.id} className="widget-item widget-item--notice" onClick={() => onPostClick(item.id)}>
<span className="widget-item-title">{item.title}</span>
<span className="widget-item-time">{item.created_at}</span>
</div>
))}
</div>
</div>
<div className="widget-card">
<div className="widget-card-head">👀 {online?.count ?? '—'} </div>
<div className="widget-card-head">
<Users className="widget-card-icon widget-card-icon--online" aria-hidden />
<span className="widget-head-count">{online?.count ?? '—'}</span>
</div>
<div className="widget-card-body">
<div style={{ fontSize: 12, color: 'var(--color-text-3)', marginBottom: 8 }}>
<div className="widget-online-meta">
{online?.members ?? 0} · {online?.guests ?? 0}
</div>
<div className="widget-online-list">
@@ -57,7 +74,7 @@ export default function RightPanel({ hot, notifications, online, onPostClick }:
</span>
))}
{members.length === 0 && (
<span style={{ fontSize: 13, color: 'var(--color-text-3)' }}>线</span>
<span className="widget-empty widget-empty--inline">线</span>
)}
</div>
</div>
@@ -71,6 +88,6 @@ export default function RightPanel({ hot, notifications, online, onPostClick }:
</p>
</div>
</div>
</>
</div>
);
}

View File

@@ -7,6 +7,8 @@ import { useAuth } from '../hooks/useAuth';
import { cn } from '@/lib/utils';
import { buildHomeUrl, parseFeedSort } from './FeedSortBar';
import { navigateFeed } from '../utils/feedCache';
import BoardIconDisplay from './BoardIconDisplay';
import { getBoardThemeIndex } from '../utils/boardTheme';
// 内容页不参与左侧栏高亮(非 feed 浏览上下文)
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile'];
@@ -60,18 +62,34 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
{boards.length > 0 && (
<>
<div className="sidebar-section" style={{ marginTop: 8 }}></div>
<div className="sidebar-section sidebar-section--boards"></div>
<nav className="sidebar-nav">
{boards.map(b => (
<button
type="button"
key={b.id}
className={cn('sidebar-nav-item', menuKey != null && menuKey === String(b.id) && 'active')}
onClick={() => { onSelectBoard(b.id); navigateFeed(nav, buildHomeUrl(b.id, sort)); }}
>
<span className="flex-1 truncate">{b.name}</span>
</button>
))}
{boards.map(b => {
const isActive = menuKey != null && menuKey === String(b.id);
const themeIdx = getBoardThemeIndex(b);
return (
<button
type="button"
key={b.id}
className={cn(
'sidebar-nav-item',
'sidebar-nav-item--board',
isActive && 'active',
isActive && `sidebar-nav-item--board-${themeIdx}`,
)}
onClick={() => { onSelectBoard(b.id); navigateFeed(nav, buildHomeUrl(b.id, sort)); }}
>
<BoardIconDisplay
board={b}
className={cn('sidebar-board-icon', `sidebar-board-icon--${themeIdx}`)}
/>
<span className="flex-1 truncate">{b.name}</span>
{(b.post_count ?? 0) > 0 && (
<span className="sidebar-nav-item__meta">{b.post_count}</span>
)}
</button>
);
})}
</nav>
</>
)}

View File

@@ -0,0 +1,45 @@
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface Props {
open: boolean;
onStay: () => void;
onLeave: () => void;
/** 编辑已有帖子时为 true */
isEdit?: boolean;
}
export default function UnsavedChangesDialog({ open, onStay, onLeave, isEdit = false }: Props) {
return (
<AlertDialog open={open}>
<AlertDialogContent onEscapeKeyDown={onStay}>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{isEdit
? '你对这篇文章的修改尚未保存,离开后将无法恢复。'
: '你正在撰写的内容尚未发布,离开后将无法恢复。'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onStay}></AlertDialogCancel>
<Button
type="button"
variant="destructive"
onClick={onLeave}
>
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -1,8 +1,8 @@
import { useRef, useEffect, useLayoutEffect } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Spinner } from '@/components/ui/spinner';
import { Button } from '@/components/ui/button';
import PostListItem from './PostListItem';
import PostListSkeleton from './PostListSkeleton';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
@@ -50,6 +50,8 @@ export default function VirtualPostList({
const showHistoryPrompt = hasMore && !canAutoLoad && !loading;
const showEnd = !hasMore && posts.length > 0 && !loading;
const isInitialLoad = loading && posts.length === 0;
const isLoadingMore = loading && posts.length > 0;
useLayoutEffect(() => {
if (resetScrollKey <= 0) return;
@@ -88,42 +90,44 @@ export default function VirtualPostList({
return (
<div className="post-list-scroll" ref={parentRef}>
<div className="content-surface" style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map(vi => {
const post = posts[vi.index];
return (
<div
key={post.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
}}
>
<PostListItem post={post} sort={sort} onClick={() => onSelect(post.id)} />
{isInitialLoad ? (
<PostListSkeleton />
) : (
<>
<div className="content-surface" style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map(vi => {
const post = posts[vi.index];
return (
<div
key={post.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
}}
>
<PostListItem post={post} sort={sort} onClick={() => onSelect(post.id)} />
</div>
);
})}
</div>
{isLoadingMore && <PostListSkeleton count={2} />}
{showHistoryPrompt && (
<div className="feed-list-footer feed-list-footer--history">
<p className="feed-list-footer__hint">
{posts.length} / {postTotal}
</p>
<Button type="button" variant="outline" size="sm" onClick={onLoadMore}>
</Button>
</div>
);
})}
</div>
{loading && (
<div className="feed-list-footer">
<Spinner />
</div>
)}
{showHistoryPrompt && (
<div className="feed-list-footer feed-list-footer--history">
<p className="feed-list-footer__hint">
{posts.length} / {postTotal}
</p>
<Button type="button" variant="outline" size="sm" onClick={onLoadMore}>
</Button>
</div>
)}
{showEnd && (
<div className="feed-list-footer feed-list-footer--end"> </div>
)}
{showEnd && (
<div className="feed-list-footer feed-list-footer--end"> </div>
)}
</>
)}
</div>
);

View File

@@ -0,0 +1,7 @@
import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('skeleton', className)} {...props} />;
}
export { Skeleton };

View File

@@ -0,0 +1,79 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useBlocker } from 'react-router-dom';
interface Options {
/** 是否存在未保存的修改 */
isDirty: boolean;
}
/**
* 拦截页面内导航与关闭标签页,在存在未保存修改时提示用户确认。
*/
export function useUnsavedChangesGuard({ isDirty }: Options) {
const [dialogOpen, setDialogOpen] = useState(false);
const pendingLeaveRef = useRef<(() => void) | null>(null);
/** 同步标记,避免 setState 未及时生效导致 useBlocker 二次拦截 */
const allowNavigationRef = useRef(false);
const shouldBlock = isDirty && !allowNavigationRef.current;
const blocker = useBlocker(() => isDirty && !allowNavigationRef.current);
useEffect(() => {
if (blocker.state === 'blocked') {
setDialogOpen(true);
}
}, [blocker.state]);
useEffect(() => {
if (!shouldBlock) return;
const onBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = '';
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, [shouldBlock]);
const stayOnPage = useCallback(() => {
setDialogOpen(false);
pendingLeaveRef.current = null;
if (blocker.state === 'blocked') {
blocker.reset?.();
}
}, [blocker]);
const discardAndLeave = useCallback(() => {
setDialogOpen(false);
allowNavigationRef.current = true;
if (blocker.state === 'blocked') {
blocker.proceed?.();
return;
}
const action = pendingLeaveRef.current;
pendingLeaveRef.current = null;
action?.();
}, [blocker]);
/** 主动发起离开(如点击返回按钮) */
const requestLeave = useCallback((action: () => void) => {
if (!shouldBlock) {
action();
return;
}
pendingLeaveRef.current = action;
setDialogOpen(true);
}, [shouldBlock]);
/** 保存成功后调用,允许后续导航不再拦截 */
const markSaved = useCallback(() => {
allowNavigationRef.current = true;
}, []);
return {
dialogOpen,
stayOnPage,
discardAndLeave,
requestLeave,
markSaved,
};
}

View File

@@ -20,6 +20,8 @@ import { useForumLimits } from '../hooks/useForumLimits';
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
import { navigateFeed } from '../utils/feedCache';
import { notify } from '@/lib/notify';
import { cn } from '@/lib/utils';
import { getBoardThemeIndex } from '../utils/boardTheme';
export default function MainLayout() {
const { user, loading: authLoading, logout } = useAuth();
@@ -221,13 +223,21 @@ export default function MainLayout() {
className={`board-chip ${mobileActiveBoard === 0 ? 'active' : ''}`}
onClick={() => { setBoardId(0); navigateFeed(nav, buildHomeUrl(0, feedSort)); }}
></span>
{boards.map(b => (
<span
key={b.id}
className={`board-chip ${mobileActiveBoard === b.id ? 'active' : ''}`}
onClick={() => { setBoardId(b.id); navigateFeed(nav, buildHomeUrl(b.id, feedSort)); }}
>{b.name}</span>
))}
{boards.map(b => {
const themeIdx = getBoardThemeIndex(b);
const isActive = mobileActiveBoard === b.id;
return (
<span
key={b.id}
className={cn(
'board-chip',
isActive && 'active',
isActive && `board-chip--${themeIdx}`,
)}
onClick={() => { setBoardId(b.id); navigateFeed(nav, buildHomeUrl(b.id, feedSort)); }}
>{b.name}</span>
);
})}
</div>
)}
<Suspense fallback={<PageLoader />}>

View File

@@ -1,5 +1,4 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -24,20 +23,25 @@ import {
Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
} from '@/components/ui/form';
import { notify } from '@/lib/notify';
import { cn } from '@/lib/utils';
import { api } from '../api/client';
import { useAdminGuard } from '../layouts/AdminLayout';
import type { Board } from '../api/types';
import { BoardColorPicker, BoardIconPicker } from '../components/BoardAppearancePicker';
import BoardIconDisplay from '../components/BoardIconDisplay';
import { getBoardThemeIndex } from '../utils/boardTheme';
const boardSchema = z.object({
name: z.string().min(1, '请输入名称').max(64),
description: z.string().max(500).optional(),
sort_order: z.coerce.number().min(0),
icon: z.string().max(64).optional(),
color_index: z.coerce.number().min(-1).max(7),
});
type BoardFormValues = z.infer<typeof boardSchema>;
export default function BoardsManagePage() {
const nav = useNavigate();
const { ready } = useAdminGuard();
const [boards, setBoards] = useState<Board[]>([]);
const [loading, setLoading] = useState(true);
@@ -47,9 +51,12 @@ export default function BoardsManagePage() {
const form = useForm<BoardFormValues>({
resolver: zodResolver(boardSchema),
defaultValues: { name: '', description: '', sort_order: 1 },
defaultValues: { name: '', description: '', sort_order: 1, icon: '', color_index: -1 },
});
const watchColorIndex = form.watch('color_index');
const editingPreviewId = editing?.id ?? boards.length + 1;
const load = () => {
setLoading(true);
api.boards()
@@ -64,7 +71,7 @@ export default function BoardsManagePage() {
const openCreate = () => {
setEditing(null);
form.reset({ name: '', description: '', sort_order: boards.length + 1 });
form.reset({ name: '', description: '', sort_order: boards.length + 1, icon: '', color_index: -1 });
setModalOpen(true);
};
@@ -74,6 +81,8 @@ export default function BoardsManagePage() {
name: board.name,
description: board.description ?? '',
sort_order: board.sort_order,
icon: board.icon ?? '',
color_index: board.color_index ?? -1,
});
setModalOpen(true);
};
@@ -81,11 +90,18 @@ export default function BoardsManagePage() {
const handleSubmit = async (values: BoardFormValues) => {
setSubmitting(true);
try {
const body = {
name: values.name,
description: values.description ?? '',
sort_order: values.sort_order,
icon: values.icon ?? '',
color_index: values.color_index ?? -1,
};
if (editing) {
await api.updateBoard(editing.id, values);
await api.updateBoard(editing.id, body);
notify.success('板块已更新');
} else {
await api.createBoard(values);
await api.createBoard(body);
notify.success('板块已创建');
}
setModalOpen(false);
@@ -119,7 +135,7 @@ export default function BoardsManagePage() {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h1></h1>
<p></p>
<p></p>
</div>
<Button onClick={openCreate}>
<Plus />
@@ -137,6 +153,7 @@ export default function BoardsManagePage() {
<TableHeader>
<TableRow>
<TableHead className="w-[60px]">ID</TableHead>
<TableHead className="w-[52px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[70px]"></TableHead>
@@ -145,9 +162,16 @@ export default function BoardsManagePage() {
</TableRow>
</TableHeader>
<TableBody>
{boards.map(board => (
{boards.map(board => {
const themeIdx = getBoardThemeIndex(board);
return (
<TableRow key={board.id}>
<TableCell>{board.id}</TableCell>
<TableCell>
<span className={cn('board-table-icon', `sidebar-board-icon--${themeIdx}`)}>
<BoardIconDisplay board={board} />
</span>
</TableCell>
<TableCell><strong>{board.name}</strong></TableCell>
<TableCell className="max-w-[200px] truncate">{board.description}</TableCell>
<TableCell>{board.sort_order}</TableCell>
@@ -179,7 +203,8 @@ export default function BoardsManagePage() {
</div>
</TableCell>
</TableRow>
))}
);
})}
</TableBody>
</Table>
{boards.length === 0 && (
@@ -192,7 +217,7 @@ export default function BoardsManagePage() {
</div>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent>
<DialogContent className="board-manage-dialog">
<DialogHeader>
<DialogTitle>{editing ? '编辑板块' : '新建板块'}</DialogTitle>
</DialogHeader>
@@ -224,6 +249,40 @@ export default function BoardsManagePage() {
</FormItem>
)}
/>
<FormField
control={form.control}
name="icon"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<BoardIconPicker
value={field.value ?? ''}
onChange={field.onChange}
board={{ id: editingPreviewId, color_index: watchColorIndex }}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="color_index"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<BoardColorPicker
value={field.value ?? -1}
onChange={field.onChange}
boardId={editingPreviewId}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sort_order"

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useNavigate, useSearchParams, useParams } from 'react-router-dom';
import { ArrowLeft, Send, Tag } from 'lucide-react';
import { notify } from '@/lib/notify';
@@ -7,9 +7,18 @@ import { useAuth } from '../hooks/useAuth';
import type { Board } from '../api/types';
import { isHtmlEmpty } from '../utils/postContent';
import { useForumLimits } from '../hooks/useForumLimits';
import { useUnsavedChangesGuard } from '../hooks/useUnsavedChangesGuard';
import ArticleEditor from '../components/ArticleEditor';
import UnsavedChangesDialog from '../components/UnsavedChangesDialog';
import { Spinner } from '@/components/ui/spinner';
interface ComposeBaseline {
title: string;
tags: string;
content: string;
boardId: string;
}
export default function ComposePage() {
const nav = useNavigate();
const { id: editIdParam } = useParams();
@@ -27,6 +36,7 @@ export default function ComposePage() {
const [content, setContent] = useState('');
const [publishing, setPublishing] = useState(false);
const [loading, setLoading] = useState(isEdit);
const [baseline, setBaseline] = useState<ComposeBaseline | null>(null);
useEffect(() => {
if (authLoading) return;
@@ -50,10 +60,17 @@ export default function ComposePage() {
nav(`/post/${editId}`);
return;
}
setBoardId(String(post.board_id));
const loadedBoardId = String(post.board_id);
setBoardId(loadedBoardId);
setTitle(post.title);
setTags(post.tags ?? '');
setContent(post.content ?? '');
setBaseline({
title: post.title,
tags: post.tags ?? '',
content: post.content ?? '',
boardId: loadedBoardId,
});
})
.catch((e: unknown) => {
notify.error(e instanceof Error ? e.message : '加载帖子失败');
@@ -66,12 +83,37 @@ export default function ComposePage() {
api.boards().then(d => {
const list = d.boards ?? [];
setBoards(list);
const initialBoardId = defaultBoard || (list.length > 0 ? String(list[0].id) : '');
if (!defaultBoard && list.length > 0) {
setBoardId(String(list[0].id));
setBoardId(initialBoardId);
}
setBaseline({
title: '',
tags: '',
content: '',
boardId: initialBoardId,
});
}).catch(() => {});
}, [user, authLoading, nav, defaultBoard, isEdit, editId]);
const isDirty = useMemo(() => {
if (!baseline) return false;
return (
title !== baseline.title
|| tags !== baseline.tags
|| content !== baseline.content
|| (!isEdit && boardId !== baseline.boardId)
);
}, [baseline, title, tags, content, boardId, isEdit]);
const {
dialogOpen,
stayOnPage,
discardAndLeave,
requestLeave,
markSaved,
} = useUnsavedChangesGuard({ isDirty });
if (authLoading) {
return (
<div className="compose-page compose-page--empty">
@@ -127,10 +169,12 @@ export default function ComposePage() {
if (isEdit) {
await api.updatePost(editId!, payload);
notify.success('帖子已更新');
markSaved();
nav(`/post/${editId}`);
} else {
const res = await api.createPost({ board_id: boardId, ...payload });
notify.success('发帖成功');
markSaved();
nav(`/post/${res.post_id}`);
}
} catch (e: unknown) {
@@ -146,7 +190,11 @@ export default function ComposePage() {
<div className="compose-page">
<div className="compose-canvas">
<header className="compose-header">
<button type="button" className="compose-back" onClick={() => nav(isEdit ? `/post/${editId}` : -1)}>
<button
type="button"
className="compose-back"
onClick={() => requestLeave(() => nav(isEdit ? `/post/${editId}` : -1))}
>
<ArrowLeft size={16} />
<span></span>
</button>
@@ -215,6 +263,12 @@ export default function ComposePage() {
/>
</div>
</div>
<UnsavedChangesDialog
open={dialogOpen}
onStay={stayOnPage}
onLeave={discardAndLeave}
isEdit={isEdit}
/>
</div>
);
}

View File

@@ -183,19 +183,20 @@ export default function HomePage() {
return (
<div className="page-wrap" ref={pageWrapRef}>
<div className="feed-top">
<FeedHeader
boardId={boardId}
keyword={keyword}
boards={ctx?.boards ?? []}
stats={ctx?.stats ?? null}
postTotal={postTotal}
/>
{showSortBar && (
<FeedSortBar value={sort} onChange={handleSortChange} postTotal={postTotal} />
)}
</div>
<VirtualPostList
<div className="feed-panel">
<div className="feed-top">
<FeedHeader
boardId={boardId}
keyword={keyword}
boards={ctx?.boards ?? []}
stats={ctx?.stats ?? null}
postTotal={postTotal}
/>
{showSortBar && (
<FeedSortBar value={sort} onChange={handleSortChange} postTotal={postTotal} />
)}
</div>
<VirtualPostList
posts={posts}
sort={sort}
loading={loading}
@@ -209,6 +210,7 @@ export default function HomePage() {
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
onScrollRestored={() => setRestoreScrollTop(null)}
/>
</div>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock } from 'lucide-re
import PinnedIcon from '@/components/PinnedIcon';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import BoardBadge from '@/components/BoardBadge';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
@@ -214,7 +215,7 @@ export default function PostDetailPage() {
</Button>
{post.board && (
<Badge variant="green" className="post-detail-board-tag">{post.board.name}</Badge>
<BoardBadge board={post.board} className="post-detail-board-tag" />
)}
</div>

View File

@@ -87,6 +87,23 @@
--j13-bg-block-accent: #f0faf5;
--j13-shadow-soft: 0 1px 2px rgba(15, 23, 42, 0.04);
--j13-shadow-card: 0 2px 8px rgba(15, 23, 42, 0.05);
/* 板块色标8 色槽,按 board id 取模) */
--board-0-color: #18a058;
--board-0-bg: rgba(24, 160, 88, 0.1);
--board-1-color: #3498db;
--board-1-bg: rgba(52, 152, 219, 0.1);
--board-2-color: #9b59b6;
--board-2-bg: rgba(155, 89, 182, 0.1);
--board-3-color: #e67e22;
--board-3-bg: rgba(230, 126, 34, 0.1);
--board-4-color: #e74c3c;
--board-4-bg: rgba(231, 76, 60, 0.1);
--board-5-color: #1abc9c;
--board-5-bg: rgba(26, 188, 156, 0.1);
--board-6-color: #6366f1;
--board-6-bg: rgba(99, 102, 241, 0.1);
--board-7-color: #f59e0b;
--board-7-bg: rgba(245, 158, 11, 0.12);
}
.dark {
@@ -104,6 +121,28 @@
--j13-bg-block-accent: rgba(35, 195, 107, 0.06);
--j13-shadow-soft: 0 1px 2px rgba(0, 0, 0, 0.2);
--j13-shadow-card: 0 2px 8px rgba(0, 0, 0, 0.28);
--board-0-color: #23c36b;
--board-0-bg: rgba(35, 195, 107, 0.14);
--board-1-color: #5dade2;
--board-1-bg: rgba(93, 173, 226, 0.14);
--board-2-color: #bb86fc;
--board-2-bg: rgba(187, 134, 252, 0.14);
--board-3-color: #f0a050;
--board-3-bg: rgba(240, 160, 80, 0.14);
--board-4-color: #ff7b72;
--board-4-bg: rgba(255, 123, 114, 0.14);
--board-5-color: #3dd6b5;
--board-5-bg: rgba(61, 214, 181, 0.14);
--board-6-color: #818cf8;
--board-6-bg: rgba(129, 140, 248, 0.14);
--board-7-color: #fbbf24;
--board-7-bg: rgba(251, 191, 36, 0.14);
}
.dark .app-shell {
background:
radial-gradient(ellipse 70% 45% at 50% -10%, rgba(35, 195, 107, 0.08), transparent 70%),
var(--j13-bg-page);
}
* { box-sizing: border-box; }
@@ -141,7 +180,9 @@ a:hover { text-decoration: underline; }
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--j13-bg-page);
background:
radial-gradient(ellipse 70% 45% at 50% -10%, rgba(24, 160, 88, 0.07), transparent 70%),
var(--j13-bg-page);
}
/* 顶栏 + 主体共用同一宽度容器,顶满高度、直角外框 */
@@ -483,6 +524,36 @@ a:hover { text-decoration: underline; }
font-size: 12px;
}
.sidebar-nav-item--board .sidebar-board-icon {
opacity: 0.88;
}
.sidebar-nav-item--board.active .sidebar-board-icon {
opacity: 1;
}
.sidebar-nav-item--board.active.sidebar-nav-item--board-0 { background: var(--board-0-bg); color: var(--board-0-color); }
.sidebar-nav-item--board.active.sidebar-nav-item--board-1 { background: var(--board-1-bg); color: var(--board-1-color); }
.sidebar-nav-item--board.active.sidebar-nav-item--board-2 { background: var(--board-2-bg); color: var(--board-2-color); }
.sidebar-nav-item--board.active.sidebar-nav-item--board-3 { background: var(--board-3-bg); color: var(--board-3-color); }
.sidebar-nav-item--board.active.sidebar-nav-item--board-4 { background: var(--board-4-bg); color: var(--board-4-color); }
.sidebar-nav-item--board.active.sidebar-nav-item--board-5 { background: var(--board-5-bg); color: var(--board-5-color); }
.sidebar-nav-item--board.active.sidebar-nav-item--board-6 { background: var(--board-6-bg); color: var(--board-6-color); }
.sidebar-nav-item--board.active.sidebar-nav-item--board-7 { background: var(--board-7-bg); color: var(--board-7-color); }
.sidebar-board-icon--0 { color: var(--board-0-color); }
.sidebar-board-icon--1 { color: var(--board-1-color); }
.sidebar-board-icon--2 { color: var(--board-2-color); }
.sidebar-board-icon--3 { color: var(--board-3-color); }
.sidebar-board-icon--4 { color: var(--board-4-color); }
.sidebar-board-icon--5 { color: var(--board-5-color); }
.sidebar-board-icon--6 { color: var(--board-6-color); }
.sidebar-board-icon--7 { color: var(--board-7-color); }
.sidebar-section--boards {
margin-top: 8px;
}
.sidebar-actions {
padding: 12px;
border-top: 1px solid var(--j13-border);
@@ -496,6 +567,7 @@ a:hover { text-decoration: underline; }
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--j13-bg-workspace);
}
/* 主内容 + 右栏:与左栏同色,整块平铺 */
@@ -516,7 +588,15 @@ a:hover { text-decoration: underline; }
padding: 0;
display: flex;
flex-direction: column;
background: var(--j13-bg-surface);
background: var(--j13-bg-workspace);
}
.aside-panel-inner {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
flex: 1;
}
@media (max-width: 1100px) { .aside-panel { display: none; } }
@@ -540,6 +620,19 @@ a:hover { text-decoration: underline; }
flex-direction: column;
scrollbar-width: none;
-ms-overflow-style: none;
padding: 12px;
}
.feed-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
border: 1px solid var(--j13-border-light);
border-radius: 12px;
background: var(--j13-bg-block);
box-shadow: var(--j13-shadow-card);
overflow: hidden;
}
.page-wrap::-webkit-scrollbar {
@@ -597,6 +690,13 @@ a:hover { text-decoration: underline; }
overflow: hidden;
}
.feed-panel .content-surface {
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
}
.section-card {
background: var(--j13-bg-block);
border: 1px solid var(--j13-border-light);
@@ -618,6 +718,19 @@ a:hover { text-decoration: underline; }
flex-shrink: 0;
border-bottom: 1px solid var(--j13-border-light);
background: var(--j13-bg-block);
position: relative;
}
.feed-top::after {
content: '';
position: absolute;
left: 16px;
right: 16px;
bottom: 0;
height: 1px;
background: linear-gradient(90deg, var(--j13-green) 0%, transparent 55%);
opacity: 0.35;
pointer-events: none;
}
@media (min-width: 900px) {
@@ -675,6 +788,37 @@ a:hover { text-decoration: underline; }
text-overflow: ellipsis;
}
.feed-head__stats {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.feed-stat-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
color: var(--color-text-3);
background: var(--j13-bg-block-muted);
white-space: nowrap;
}
.feed-stat-chip svg {
width: 12px;
height: 12px;
color: var(--j13-green);
opacity: 0.85;
}
.feed-stat-chip strong {
color: var(--j13-green);
font-weight: 600;
}
.feed-head__meta strong {
color: var(--j13-green);
font-weight: 600;
@@ -783,6 +927,15 @@ a:hover { text-decoration: underline; }
font-weight: 600;
}
.board-chip.active.board-chip--0 { background: var(--board-0-bg); color: var(--board-0-color); }
.board-chip.active.board-chip--1 { background: var(--board-1-bg); color: var(--board-1-color); }
.board-chip.active.board-chip--2 { background: var(--board-2-bg); color: var(--board-2-color); }
.board-chip.active.board-chip--3 { background: var(--board-3-bg); color: var(--board-3-color); }
.board-chip.active.board-chip--4 { background: var(--board-4-bg); color: var(--board-4-color); }
.board-chip.active.board-chip--5 { background: var(--board-5-bg); color: var(--board-5-color); }
.board-chip.active.board-chip--6 { background: var(--board-6-bg); color: var(--board-6-color); }
.board-chip.active.board-chip--7 { background: var(--board-7-bg); color: var(--board-7-color); }
.board-grid {
display: flex;
flex-wrap: wrap;
@@ -845,11 +998,22 @@ a:hover { text-decoration: underline; }
padding: 8px 12px 6px;
}
.feed-head__meta { display: none; }
.feed-head__meta,
.feed-head__stats { display: none; }
.feed-toolbar {
padding: 0 12px 8px;
}
.page-wrap {
padding: 0;
}
.feed-panel {
border: none;
border-radius: 0;
box-shadow: none;
}
}
.board-grid-empty {
@@ -951,10 +1115,34 @@ a:hover { text-decoration: underline; }
padding: 12px 20px;
border-bottom: 1px solid var(--j13-border-light);
cursor: pointer;
transition: background 0.15s;
transition: background 0.15s, box-shadow 0.15s;
position: relative;
}
.post-row:hover { background: var(--j13-bg-block-muted); }
.post-row::before {
content: '';
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 3px;
border-radius: 0 3px 3px 0;
background: var(--j13-green);
opacity: 0;
transition: opacity 0.15s;
}
.post-row:hover {
background: var(--j13-bg-block-accent);
}
.post-row:hover::before {
opacity: 1;
}
.post-row:hover .post-title {
color: var(--j13-green);
}
.post-avatar {
width: 36px;
@@ -969,11 +1157,26 @@ a:hover { text-decoration: underline; }
justify-content: center;
flex-shrink: 0;
overflow: hidden;
box-shadow: 0 0 0 2px var(--j13-bg-block), 0 0 0 3px var(--j13-border);
transition: box-shadow 0.15s;
}
.post-row:hover .post-avatar {
box-shadow: 0 0 0 2px var(--j13-bg-block), 0 0 0 3px var(--j13-green);
}
.post-avatar img { width: 100%; height: 100%; object-fit: cover; }
.post-body { flex: 1; min-width: 0; }
.post-title { font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 4px; }
.post-title {
font-size: 14px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 4px;
color: var(--color-text-1);
transition: color 0.15s;
}
.post-pinned-icon {
display: inline-block;
vertical-align: -0.15em;
@@ -981,8 +1184,266 @@ a:hover { text-decoration: underline; }
color: #e53935;
stroke-width: 1.5;
}
.post-meta { font-size: 12px; color: var(--color-text-3); display: flex; flex-wrap: wrap; gap: 8px; }
.post-stats { display: flex; gap: 12px; font-size: 12px; color: var(--color-text-3); flex-shrink: 0; padding-top: 4px; }
.post-meta { font-size: 12px; color: var(--color-text-3); display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.post-stats {
display: flex;
gap: 8px;
flex-shrink: 0;
padding-top: 2px;
}
.post-stat {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 999px;
font-size: 11px;
color: var(--color-text-3);
background: var(--j13-bg-block-muted);
transition: background 0.15s, color 0.15s;
}
.post-stat svg {
width: 12px;
height: 12px;
flex-shrink: 0;
}
.post-row:hover .post-stat {
background: var(--j13-green-bg);
color: var(--j13-green);
}
.post-stat--zero {
opacity: 0.55;
}
/* 板块色标 Badge */
.board-badge {
display: inline-flex;
align-items: center;
padding: 1px 7px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
line-height: 1.5;
white-space: nowrap;
}
.board-badge--0 { background: var(--board-0-bg); color: var(--board-0-color); }
.board-badge--1 { background: var(--board-1-bg); color: var(--board-1-color); }
.board-badge--2 { background: var(--board-2-bg); color: var(--board-2-color); }
.board-badge--3 { background: var(--board-3-bg); color: var(--board-3-color); }
.board-badge--4 { background: var(--board-4-bg); color: var(--board-4-color); }
.board-badge--5 { background: var(--board-5-bg); color: var(--board-5-color); }
.board-badge--6 { background: var(--board-6-bg); color: var(--board-6-color); }
.board-badge--7 { background: var(--board-7-bg); color: var(--board-7-color); }
/* 板块管理:图标 / 色标选择器 */
.board-manage-dialog {
max-width: 520px;
}
.board-appearance-picker__preview {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.board-appearance-picker__preview-icon {
width: 36px;
height: 36px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--j13-bg-block-muted);
}
.board-appearance-picker__preview-icon svg {
width: 20px;
height: 20px;
}
.board-appearance-picker__preview-hint {
font-size: 12px;
color: var(--color-text-3);
}
.board-icon-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 6px;
}
.board-icon-option {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 1;
border: 1px solid var(--j13-border-light);
border-radius: 8px;
background: var(--j13-bg-block);
color: var(--color-text-2);
cursor: pointer;
transition: border-color 0.15s, background 0.15s, color 0.15s;
}
.board-icon-option svg {
width: 18px;
height: 18px;
}
.board-icon-option:hover {
border-color: var(--j13-green);
background: var(--j13-bg-block-muted);
}
.board-icon-option--active {
border-color: var(--j13-green);
background: var(--j13-green-bg);
color: var(--j13-green);
}
.board-icon-option__auto {
font-size: 12px;
font-weight: 700;
color: var(--color-text-3);
}
.board-color-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.board-color-option {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.12s, box-shadow 0.12s;
}
.board-color-option:hover {
transform: scale(1.08);
}
.board-color-option--active {
box-shadow: 0 0 0 2px var(--j13-bg-block), 0 0 0 3px var(--j13-green);
}
.board-color-option--auto {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--j13-bg-block-muted);
font-size: 11px;
font-weight: 700;
color: var(--color-text-3);
}
.board-color-option--0 { background: var(--board-0-color); }
.board-color-option--1 { background: var(--board-1-color); }
.board-color-option--2 { background: var(--board-2-color); }
.board-color-option--3 { background: var(--board-3-color); }
.board-color-option--4 { background: var(--board-4-color); }
.board-color-option--5 { background: var(--board-5-color); }
.board-color-option--6 { background: var(--board-6-color); }
.board-color-option--7 { background: var(--board-7-color); }
.board-table-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--j13-bg-block-muted);
}
.board-table-icon svg {
width: 18px;
height: 18px;
}
/* 帖子列表骨架屏 */
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton {
background: linear-gradient(
90deg,
var(--j13-bg-block-muted) 0%,
var(--j13-bg-block) 45%,
var(--j13-bg-block-muted) 90%
);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s ease-in-out infinite;
border-radius: 6px;
}
.post-row--skeleton {
pointer-events: none;
cursor: default;
}
.post-row--skeleton::before {
display: none;
}
.post-row--skeleton:hover {
background: transparent;
}
.skeleton--avatar {
width: 36px;
height: 36px;
border-radius: 50%;
flex-shrink: 0;
}
.skeleton--title {
height: 14px;
margin-bottom: 8px;
max-width: 100%;
}
.skeleton-meta-row {
display: flex;
align-items: center;
gap: 8px;
}
.skeleton--badge {
width: 52px;
height: 18px;
border-radius: 4px;
flex-shrink: 0;
}
.skeleton--meta {
height: 12px;
width: 56px;
flex-shrink: 0;
}
.skeleton--meta-short {
width: 72px;
}
.skeleton--stat {
width: 36px;
height: 22px;
border-radius: 999px;
}
.empty-state { text-align: center; padding: 60px 24px; color: var(--color-text-3); }
@@ -2492,26 +2953,43 @@ a.waline-comment-author:hover { color: var(--j13-green); }
}
.widget-card {
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
background: var(--j13-bg-block);
border: 1px solid var(--j13-border-light);
border-radius: 10px;
box-shadow: var(--j13-shadow-soft);
margin-bottom: 0;
overflow: hidden;
border-bottom: 1px solid var(--j13-border-light);
flex-shrink: 0;
}
.widget-card-head {
padding: 12px 16px 8px;
display: flex;
align-items: center;
gap: 6px;
padding: 10px 14px 8px;
font-size: 13px;
font-weight: 600;
border-bottom: none;
background: transparent;
border-bottom: 1px solid var(--j13-border-light);
background: var(--j13-bg-block-muted);
color: var(--color-text-2);
}
.widget-card-body { padding: 0 16px 14px; }
.widget-card-icon {
width: 15px;
height: 15px;
flex-shrink: 0;
}
.widget-card-icon--hot { color: #e74c3c; }
.widget-card-icon--notice { color: #3498db; }
.widget-card-icon--online { color: var(--j13-green); }
.widget-head-count {
color: var(--j13-green);
font-weight: 700;
}
.widget-card-body { padding: 6px 14px 12px; }
.widget-card--about {
flex: 1;
@@ -2545,17 +3023,85 @@ a.waline-comment-author:hover { color: var(--j13-green); }
}
.widget-item {
padding: 6px 0;
padding: 7px 0;
font-size: 13px;
cursor: pointer;
border-bottom: 1px solid var(--j13-border-light);
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
border-radius: 4px;
transition: background 0.12s, color 0.12s;
}
.widget-item:last-child { border-bottom: none; }
.widget-item:hover { color: var(--j13-green); }
.widget-item:hover {
color: var(--j13-green);
background: var(--j13-bg-block-accent);
padding-left: 6px;
padding-right: 6px;
margin-left: -6px;
margin-right: -6px;
}
.widget-item-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.widget-item-time {
font-size: 11px;
color: var(--color-text-4);
flex-shrink: 0;
}
.widget-rank {
min-width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
color: var(--color-text-3);
background: var(--j13-bg-block-muted);
flex-shrink: 0;
}
.widget-rank--1 {
color: #fff;
background: linear-gradient(135deg, #e74c3c, #c0392b);
}
.widget-rank--2 {
color: #fff;
background: linear-gradient(135deg, #e67e22, #d35400);
}
.widget-rank--3 {
color: #fff;
background: linear-gradient(135deg, #f1c40f, #e67e22);
}
.widget-empty {
font-size: 13px;
color: var(--color-text-3);
padding: 8px 0;
}
.widget-empty--inline {
padding: 0;
}
.widget-online-meta {
font-size: 12px;
color: var(--color-text-3);
margin-bottom: 8px;
}
.widget-online-list {
display: flex;

View File

@@ -0,0 +1,71 @@
import {
Code2, Coffee, HelpCircle, MessageSquare, Lightbulb,
BookOpen, Gamepad2, Palette, Music, Camera, Heart, Zap,
Globe, Users, Briefcase, GraduationCap, ShoppingBag, MapPin,
Megaphone, Flame, Star, Folder, Wrench, Cpu, type LucideIcon,
} from 'lucide-react';
import type { Board } from '../api/types';
/** 板块主题色槽位数(与 global.css 中 board-badge--N 对应) */
export const BOARD_PALETTE_SIZE = 8;
/** 可选板块图标key 须与后端 AllowedBoardIcons 一致) */
export const BOARD_ICON_OPTIONS: { key: string; label: string; Icon: LucideIcon }[] = [
{ key: 'code-2', label: '代码', Icon: Code2 },
{ key: 'coffee', label: '咖啡', Icon: Coffee },
{ key: 'help-circle', label: '问答', Icon: HelpCircle },
{ key: 'message-square', label: '讨论', Icon: MessageSquare },
{ key: 'lightbulb', label: '灵感', Icon: Lightbulb },
{ key: 'book-open', label: '阅读', Icon: BookOpen },
{ key: 'gamepad-2', label: '游戏', Icon: Gamepad2 },
{ key: 'palette', label: '设计', Icon: Palette },
{ key: 'music', label: '音乐', Icon: Music },
{ key: 'camera', label: '摄影', Icon: Camera },
{ key: 'heart', label: '生活', Icon: Heart },
{ key: 'zap', label: '快讯', Icon: Zap },
{ key: 'globe', label: '综合', Icon: Globe },
{ key: 'users', label: '社区', Icon: Users },
{ key: 'briefcase', label: '职场', Icon: Briefcase },
{ key: 'graduation-cap', label: '学习', Icon: GraduationCap },
{ key: 'shopping-bag', label: '交易', Icon: ShoppingBag },
{ key: 'map-pin', label: '本地', Icon: MapPin },
{ key: 'megaphone', label: '公告', Icon: Megaphone },
{ key: 'flame', label: '热门', Icon: Flame },
{ key: 'star', label: '精华', Icon: Star },
{ key: 'folder', label: '资源', Icon: Folder },
{ key: 'wrench', label: '工具', Icon: Wrench },
{ key: 'cpu', label: '硬件', Icon: Cpu },
];
const ICON_MAP = Object.fromEntries(
BOARD_ICON_OPTIONS.map(o => [o.key, o.Icon]),
) as Record<string, LucideIcon>;
const DEFAULT_ICONS: LucideIcon[] = [
Code2, Coffee, HelpCircle, MessageSquare,
Lightbulb, BookOpen, Gamepad2, Palette,
];
export type BoardVisual = Pick<Board, 'id' | 'icon' | 'color_index'>;
/** 按板块 id / color_index 取稳定色槽索引 */
export function getBoardThemeIndex(board: BoardVisual | number): number {
if (typeof board === 'object' && board.color_index != null && board.color_index >= 0) {
return board.color_index % BOARD_PALETTE_SIZE;
}
const id = typeof board === 'number' ? board : board.id;
return ((id % BOARD_PALETTE_SIZE) + BOARD_PALETTE_SIZE) % BOARD_PALETTE_SIZE;
}
/** 解析板块图标:优先使用后台配置,否则按 id 回退 */
export function getBoardIcon(board: BoardVisual | number): LucideIcon {
if (typeof board === 'object' && board.icon && ICON_MAP[board.icon]) {
return ICON_MAP[board.icon];
}
return DEFAULT_ICONS[getBoardThemeIndex(board)];
}
export function getBoardIconOption(key: string | undefined) {
if (!key) return undefined;
return BOARD_ICON_OPTIONS.find(o => o.key === key);
}