支持板块图标与主题色自定义,并优化列表加载与未保存离开提示。
新增后端图标校验与色槽规范化,前端展示 BoardBadge/骨架屏,发帖与板块管理页增加未保存确认。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface Board {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
color_index?: number;
|
||||
sort_order: number;
|
||||
post_count?: number;
|
||||
}
|
||||
|
||||
93
frontend/src/components/BoardAppearancePicker.tsx
Normal file
93
frontend/src/components/BoardAppearancePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
frontend/src/components/BoardBadge.tsx
Normal file
18
frontend/src/components/BoardBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
frontend/src/components/BoardIconDisplay.tsx
Normal file
14
frontend/src/components/BoardIconDisplay.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
30
frontend/src/components/PostListSkeleton.tsx
Normal file
30
frontend/src/components/PostListSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
45
frontend/src/components/UnsavedChangesDialog.tsx
Normal file
45
frontend/src/components/UnsavedChangesDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
7
frontend/src/components/ui/skeleton.tsx
Normal file
7
frontend/src/components/ui/skeleton.tsx
Normal 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 };
|
||||
79
frontend/src/hooks/useUnsavedChangesGuard.ts
Normal file
79
frontend/src/hooks/useUnsavedChangesGuard.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 />}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
71
frontend/src/utils/boardTheme.ts
Normal file
71
frontend/src/utils/boardTheme.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user