完善论坛配置与发帖体验:TipTap 富文本、图片上传、修订历史、Feed 排序与后台参数管理。
同步更新 README 与 ROADMAP,反映最新功能与开发状态。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
15
frontend/package-lock.json
generated
15
frontend/package-lock.json
generated
@@ -31,9 +31,7 @@
|
||||
"diff": "^9.0.0",
|
||||
"dompurify": "^3.4.10",
|
||||
"lucide-react": "^1.18.0",
|
||||
"marked": "^18.0.5",
|
||||
"postcss": "^8.5.15",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
@@ -3181,18 +3179,6 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.5",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz",
|
||||
"integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@@ -3655,6 +3641,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
|
||||
@@ -32,9 +32,7 @@
|
||||
"diff": "^9.0.0",
|
||||
"dompurify": "^3.4.10",
|
||||
"lucide-react": "^1.18.0",
|
||||
"marked": "^18.0.5",
|
||||
"postcss": "^8.5.15",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats, AdminDashboard, AdminSettings, PostDetailResponse, PostRevision } from './types';
|
||||
import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision } from './types';
|
||||
|
||||
const BASE = '';
|
||||
|
||||
@@ -24,13 +24,17 @@ async function request<T>(url: string, opts: RequestInit = {}): Promise<T> {
|
||||
export const api = {
|
||||
me: () => request<{ user: User | null }>('/api/me'),
|
||||
stats: () => request<ForumStats>('/api/stats'),
|
||||
forumLimits: () => request<ForumLimitsPublic>('/api/forum-limits'),
|
||||
boards: () => request<{ boards: Board[] }>('/api/boards'),
|
||||
posts: (params: Record<string, string | number>) => {
|
||||
const q = new URLSearchParams(params as Record<string, string>).toString();
|
||||
return request<{ posts: PostItem[]; total: number; page: number; has_more: boolean }>(`/api/posts?${q}`);
|
||||
},
|
||||
hotPosts: () => request<{ posts: PostItem[] }>('/api/posts/hot'),
|
||||
post: (id: number) => request<PostDetailResponse>(`/api/posts/${id}`),
|
||||
post: (id: number, opts?: { skipView?: boolean }) => {
|
||||
const q = opts?.skipView ? '?skip_view=1' : '';
|
||||
return request<PostDetailResponse>(`/api/posts/${id}${q}`);
|
||||
},
|
||||
comments: (id: number, myIds?: number[]) => {
|
||||
const q = myIds?.length ? `?my_ids=${myIds.join(',')}` : '';
|
||||
return request<{ comments: Comment[]; total: number }>(`/api/posts/${id}/comments${q}`);
|
||||
@@ -64,10 +68,14 @@ export const api = {
|
||||
request<{ message: string; edit_locked: boolean }>(`/api/admin/posts/${id}/lock`, {
|
||||
method: 'POST', body: JSON.stringify({ locked }),
|
||||
}),
|
||||
adminUpdateForumSettings: (body: { post_edit_window_hours: number }) =>
|
||||
request<{ message: string; post_edit_window_hours: number }>('/api/admin/settings/forum', {
|
||||
adminUpdateForumSettings: (body: ForumLimits) =>
|
||||
request<{ message: string; limits: ForumLimits }>('/api/admin/settings/forum', {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
}),
|
||||
adminUpdateFilterWords: (content: string) =>
|
||||
request<{ message: string; word_count: number }>('/api/admin/settings/filter-words', {
|
||||
method: 'PUT', body: JSON.stringify({ content }),
|
||||
}),
|
||||
postRevisions: (id: number) =>
|
||||
request<{ revisions: PostRevision[] }>(`/api/posts/${id}/revisions`),
|
||||
postRevision: (id: number, revId: number) =>
|
||||
@@ -104,6 +112,11 @@ export const api = {
|
||||
fd.append('avatar', file);
|
||||
return request<{ avatar: string }>('/api/profile/avatar', { method: 'POST', body: fd, headers: {} });
|
||||
},
|
||||
uploadPostImage: (file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('image', file);
|
||||
return request<{ url: string }>('/api/uploads/image', { method: 'POST', body: fd, headers: {} });
|
||||
},
|
||||
createPost: (data: { board_id: string; title: string; content: string; tags?: string }) => {
|
||||
const fd = new FormData();
|
||||
fd.append('board_id', data.board_id);
|
||||
|
||||
@@ -90,12 +90,44 @@ export interface AdminDashboard {
|
||||
recent_posts: PostItem[];
|
||||
}
|
||||
|
||||
export interface ForumLimits {
|
||||
post_edit_window_hours: number;
|
||||
rate_limit_post: number;
|
||||
rate_limit_comment: number;
|
||||
rate_limit_register: number;
|
||||
rate_limit_login: number;
|
||||
rate_limit_window_sec: number;
|
||||
post_title_max: number;
|
||||
post_tags_max: number;
|
||||
post_content_max: number;
|
||||
comment_max: number;
|
||||
search_keyword_min: number;
|
||||
search_keyword_max: number;
|
||||
page_size_default: number;
|
||||
page_size_max: number;
|
||||
password_min_len: number;
|
||||
avatar_max_mb: number;
|
||||
}
|
||||
|
||||
export interface ForumLimitsPublic {
|
||||
post_title_max: number;
|
||||
post_tags_max: number;
|
||||
post_content_max: number;
|
||||
comment_max: number;
|
||||
search_keyword_min: number;
|
||||
search_keyword_max: number;
|
||||
password_min_len: number;
|
||||
avatar_max_mb: number;
|
||||
}
|
||||
|
||||
export interface AdminSettings {
|
||||
filter_path: string;
|
||||
data_dir: string;
|
||||
db_path: string;
|
||||
port: number;
|
||||
post_edit_window_hours: number;
|
||||
limits: ForumLimits;
|
||||
filter_words: string;
|
||||
filter_word_count: number;
|
||||
}
|
||||
|
||||
export interface Paginated<T> {
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
|
||||
} from 'lucide-react';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
|
||||
import { countWords } from '../utils/markdown';
|
||||
import { countWords } from '../utils/text';
|
||||
import { api } from '../api/client';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { MembersOnly } from './editor/MembersOnlyExtension';
|
||||
|
||||
export interface ArticleEditorHandle {
|
||||
@@ -134,9 +136,20 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
|
||||
const setImage = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const url = window.prompt('图片地址');
|
||||
if (!url?.trim()) return;
|
||||
editor.chain().focus().setImage({ src: url.trim() }).run();
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const { url } = await api.uploadPostImage(file);
|
||||
editor.chain().focus().setImage({ src: url }).run();
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '图片上传失败');
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}, [editor]);
|
||||
|
||||
const wrapMembersOnly = useCallback(() => {
|
||||
@@ -213,7 +226,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
},
|
||||
{
|
||||
icon: <ImageIcon size={15} />,
|
||||
title: '图片',
|
||||
title: '上传图片',
|
||||
action: setImage,
|
||||
},
|
||||
{
|
||||
|
||||
46
frontend/src/hooks/useForumLimits.ts
Normal file
46
frontend/src/hooks/useForumLimits.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { ForumLimitsPublic } from '../api/types';
|
||||
|
||||
const DEFAULT_LIMITS: ForumLimitsPublic = {
|
||||
post_title_max: 128,
|
||||
post_tags_max: 256,
|
||||
post_content_max: 50000,
|
||||
comment_max: 5000,
|
||||
search_keyword_min: 1,
|
||||
search_keyword_max: 50,
|
||||
password_min_len: 6,
|
||||
avatar_max_mb: 2,
|
||||
};
|
||||
|
||||
let cached: ForumLimitsPublic | null = null;
|
||||
let inflight: Promise<ForumLimitsPublic> | null = null;
|
||||
|
||||
function fetchLimits(): Promise<ForumLimitsPublic> {
|
||||
if (cached) return Promise.resolve(cached);
|
||||
if (inflight) return inflight;
|
||||
inflight = api.forumLimits()
|
||||
.then(limits => {
|
||||
cached = limits;
|
||||
return limits;
|
||||
})
|
||||
.catch(() => DEFAULT_LIMITS)
|
||||
.finally(() => { inflight = null; });
|
||||
return inflight;
|
||||
}
|
||||
|
||||
/** 获取前台可见的论坛限制配置 */
|
||||
export function useForumLimits() {
|
||||
const [limits, setLimits] = useState<ForumLimitsPublic>(cached ?? DEFAULT_LIMITS);
|
||||
const [loading, setLoading] = useState(!cached);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLimits().then(setLimits).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return { limits, loading };
|
||||
}
|
||||
|
||||
export function invalidateForumLimitsCache() {
|
||||
cached = null;
|
||||
}
|
||||
@@ -16,7 +16,9 @@ import type { Board, PostItem, Notification, OnlineStats, ForumStats } from '../
|
||||
import { getCachedBoards, getCachedStats, setCachedBoards, setCachedStats } from '../utils/layoutCache';
|
||||
import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
|
||||
import RightPanel from '../components/RightPanel';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
|
||||
import { notify } from '@/lib/notify';
|
||||
|
||||
export default function MainLayout() {
|
||||
const { user, loading: authLoading, logout } = useAuth();
|
||||
@@ -36,6 +38,7 @@ export default function MainLayout() {
|
||||
const [boardId, setBoardId] = useState(Number(params.get('board')) || 0);
|
||||
const [keyword, setKeyword] = useState(params.get('keyword') || '');
|
||||
const feedSort = parseFeedSort(params.get('sort'));
|
||||
const { limits: forumLimits } = useForumLimits();
|
||||
|
||||
useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]);
|
||||
useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]);
|
||||
@@ -85,8 +88,21 @@ export default function MainLayout() {
|
||||
}, [refreshBoards, refreshOnline]);
|
||||
|
||||
const doSearch = () => {
|
||||
if (keyword.trim()) nav(`/?keyword=${encodeURIComponent(keyword.trim())}`);
|
||||
else nav('/');
|
||||
const kw = keyword.trim();
|
||||
if (!kw) {
|
||||
nav('/');
|
||||
return;
|
||||
}
|
||||
const len = [...kw].length;
|
||||
if (forumLimits.search_keyword_min > 0 && len < forumLimits.search_keyword_min) {
|
||||
notify.warning(`搜索关键词至少 ${forumLimits.search_keyword_min} 个字`);
|
||||
return;
|
||||
}
|
||||
if (forumLimits.search_keyword_max > 0 && len > forumLimits.search_keyword_max) {
|
||||
notify.warning(`搜索关键词最多 ${forumLimits.search_keyword_max} 个字`);
|
||||
return;
|
||||
}
|
||||
nav(`/?keyword=${encodeURIComponent(kw)}`);
|
||||
};
|
||||
|
||||
const userInitial = user?.nickname?.charAt(0) || '?';
|
||||
@@ -111,6 +127,7 @@ export default function MainLayout() {
|
||||
placeholder="搜索帖子..."
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined}
|
||||
onKeyDown={e => e.key === 'Enter' && doSearch()}
|
||||
/>
|
||||
{keyword && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api } from '../api/client';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import type { Board } from '../api/types';
|
||||
import { isHtmlEmpty } from '../utils/postContent';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import ArticleEditor from '../components/ArticleEditor';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
@@ -17,6 +18,7 @@ export default function ComposePage() {
|
||||
const [params] = useSearchParams();
|
||||
const defaultBoard = params.get('board') || '';
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const { limits } = useForumLimits();
|
||||
|
||||
const [boards, setBoards] = useState<Board[]>([]);
|
||||
const [boardId, setBoardId] = useState(defaultBoard);
|
||||
@@ -32,7 +34,7 @@ export default function ComposePage() {
|
||||
|
||||
if (isEdit) {
|
||||
setLoading(true);
|
||||
Promise.all([api.boards(), api.post(editId!)])
|
||||
Promise.all([api.boards(), api.post(editId!, { skipView: true })])
|
||||
.then(([boardsData, postData]) => {
|
||||
const list = boardsData.boards ?? [];
|
||||
setBoards(list);
|
||||
@@ -187,7 +189,7 @@ export default function ComposePage() {
|
||||
placeholder="添加标签,逗号分隔"
|
||||
value={tags}
|
||||
onChange={e => setTags(e.target.value)}
|
||||
maxLength={128}
|
||||
maxLength={limits.post_tags_max > 0 ? limits.post_tags_max : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,7 +201,7 @@ export default function ComposePage() {
|
||||
placeholder="输入文章标题…"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
maxLength={256}
|
||||
maxLength={limits.post_title_max > 0 ? limits.post_title_max : undefined}
|
||||
/>
|
||||
{currentBoard && (
|
||||
<div className="compose-subtitle">
|
||||
|
||||
@@ -12,14 +12,15 @@ import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { api } from '../api/client';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
|
||||
const nickSchema = z.object({
|
||||
nickname: z.string().min(1, '请输入昵称').max(64),
|
||||
});
|
||||
|
||||
const pwdSchema = z.object({
|
||||
const pwdSchema = (minLen: number) => z.object({
|
||||
old_password: z.string().min(1, '请输入当前密码'),
|
||||
new_password: z.string().min(6, '新密码至少 6 位'),
|
||||
new_password: z.string().min(minLen, `新密码至少 ${minLen} 位`),
|
||||
confirm_password: z.string().min(1, '请确认新密码'),
|
||||
}).refine(d => d.new_password === d.confirm_password, {
|
||||
message: '两次输入的新密码不一致',
|
||||
@@ -27,7 +28,7 @@ const pwdSchema = z.object({
|
||||
});
|
||||
|
||||
type NickValues = z.infer<typeof nickSchema>;
|
||||
type PwdValues = z.infer<typeof pwdSchema>;
|
||||
type PwdValues = z.infer<ReturnType<typeof pwdSchema>>;
|
||||
|
||||
export default function ProfilePage() {
|
||||
const nav = useNavigate();
|
||||
@@ -37,13 +38,15 @@ export default function ProfilePage() {
|
||||
const [avatarLoading, setAvatarLoading] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { limits } = useForumLimits();
|
||||
|
||||
const nickForm = useForm<NickValues>({
|
||||
resolver: zodResolver(nickSchema),
|
||||
values: { nickname: user?.nickname ?? '' },
|
||||
});
|
||||
|
||||
const pwdForm = useForm<PwdValues>({
|
||||
resolver: zodResolver(pwdSchema),
|
||||
resolver: zodResolver(pwdSchema(limits.password_min_len)),
|
||||
defaultValues: { old_password: '', new_password: '', confirm_password: '' },
|
||||
});
|
||||
|
||||
@@ -90,8 +93,8 @@ export default function ProfilePage() {
|
||||
const onAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
notify.error('头像不能超过 2MB');
|
||||
if (file.size > limits.avatar_max_mb * 1024 * 1024) {
|
||||
notify.error(`头像不能超过 ${limits.avatar_max_mb}MB`);
|
||||
return;
|
||||
}
|
||||
setAvatarLoading(true);
|
||||
|
||||
@@ -8,22 +8,24 @@ import { Input } from '@/components/ui/input';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
const schema = z.object({
|
||||
const schema = (minLen: number) => z.object({
|
||||
username: z.string().min(1, '请输入用户名'),
|
||||
nickname: z.string().optional(),
|
||||
password: z.string().min(6, '密码至少 6 位'),
|
||||
password: z.string().min(minLen, `密码至少 ${minLen} 位`),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
type FormValues = z.infer<ReturnType<typeof schema>>;
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { limits } = useForumLimits();
|
||||
const nav = useNavigate();
|
||||
const { refresh } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
resolver: zodResolver(schema(limits.password_min_len)),
|
||||
defaultValues: { username: '', nickname: '', password: '' },
|
||||
});
|
||||
|
||||
@@ -82,7 +84,7 @@ export default function RegisterPage() {
|
||||
<FormItem>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="至少 6 位" autoComplete="new-password" {...field} />
|
||||
<Input type="password" placeholder={`至少 ${limits.password_min_len} 位`} autoComplete="new-password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -15,6 +15,16 @@ import { api } from '../../api/client';
|
||||
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||
import type { PostItem } from '../../api/types';
|
||||
import { clearAllFeedCache } from '../../utils/feedCache';
|
||||
import { isTimeDiffSignificant } from '../../utils/content';
|
||||
|
||||
function formatAdminTime(iso: string) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export default function AdminPostsPage() {
|
||||
const nav = useNavigate();
|
||||
@@ -80,7 +90,7 @@ export default function AdminPostsPage() {
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h1>帖子管理</h1>
|
||||
<p>置顶、删除帖子,搜索标题关键词</p>
|
||||
<p>置顶、锁定编辑、删除帖子;支持按标题、标签或正文关键词搜索</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
@@ -90,7 +100,7 @@ export default function AdminPostsPage() {
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
placeholder="搜索帖子标题…"
|
||||
placeholder="搜索标题、标签或正文…"
|
||||
/>
|
||||
<Button type="submit"><Search size={16} />搜索</Button>
|
||||
{search && (
|
||||
@@ -112,6 +122,8 @@ export default function AdminPostsPage() {
|
||||
<th>标题</th>
|
||||
<th>板块</th>
|
||||
<th>作者</th>
|
||||
<th>标签</th>
|
||||
<th>评论</th>
|
||||
<th>置顶</th>
|
||||
<th>锁定</th>
|
||||
<th>点赞</th>
|
||||
@@ -121,21 +133,33 @@ export default function AdminPostsPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{posts.map(p => (
|
||||
{posts.map(p => {
|
||||
const edited = p.updated_at && isTimeDiffSignificant(p.created_at, p.updated_at);
|
||||
return (
|
||||
<tr key={p.id}>
|
||||
<td>{p.id}</td>
|
||||
<td className="max-w-[220px] truncate">
|
||||
<td className="max-w-[200px] truncate">
|
||||
<button type="button" className="admin-text-link" onClick={() => nav(`/post/${p.id}`)}>
|
||||
{p.title}
|
||||
</button>
|
||||
{edited && <Badge variant="secondary" className="ml-1">已编辑</Badge>}
|
||||
</td>
|
||||
<td>{p.board?.name ?? '—'}</td>
|
||||
<td>{p.user?.nickname ?? '—'}</td>
|
||||
<td className="max-w-[120px] truncate text-muted-foreground">{p.tags || '—'}</td>
|
||||
<td>{p.comment_count ?? 0}</td>
|
||||
<td>{p.pinned ? <Badge variant="orange">是</Badge> : '—'}</td>
|
||||
<td>{p.edit_locked ? <Badge variant="destructive">是</Badge> : '—'}</td>
|
||||
<td>{p.like_count}</td>
|
||||
<td>{p.view_count}</td>
|
||||
<td>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
|
||||
<td className="text-sm whitespace-nowrap">
|
||||
<span>{formatAdminTime(p.created_at)}</span>
|
||||
{edited && p.updated_at && (
|
||||
<span className="block text-muted-foreground text-xs">
|
||||
改于 {formatAdminTime(p.updated_at)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="outline" onClick={() => togglePin(p)}>
|
||||
@@ -162,7 +186,8 @@ export default function AdminPostsPage() {
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{posts.length === 0 && <div className="admin-empty">没有找到帖子</div>}
|
||||
|
||||
@@ -1,42 +1,173 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Database } from 'lucide-react';
|
||||
import { Database, Shield, Server, SlidersHorizontal } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../../api/client';
|
||||
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||
import type { AdminSettings } from '../../api/types';
|
||||
import { invalidateForumLimitsCache } from '../../hooks/useForumLimits';
|
||||
import type { AdminSettings, ForumLimits } from '../../api/types';
|
||||
|
||||
type TabId = 'limits' | 'filter' | 'system';
|
||||
|
||||
type SettingRow = {
|
||||
key: keyof ForumLimits;
|
||||
label: string;
|
||||
unit?: string;
|
||||
hint?: string;
|
||||
min?: number;
|
||||
};
|
||||
|
||||
type SettingSection = {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
rows: SettingRow[];
|
||||
};
|
||||
|
||||
const SETTING_SECTIONS: SettingSection[] = [
|
||||
{
|
||||
id: 'rule',
|
||||
title: '编辑规则',
|
||||
summary: '控制普通用户修改自己帖子的时限',
|
||||
rows: [
|
||||
{ key: 'post_edit_window_hours', label: '可编辑时限', unit: '小时', hint: '0 = 不限', min: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rate',
|
||||
title: '操作限流',
|
||||
summary: '同一用户或 IP 在窗口期内的最大请求次数',
|
||||
rows: [
|
||||
{ key: 'rate_limit_window_sec', label: '限流窗口', unit: '秒', min: 10 },
|
||||
{ key: 'rate_limit_post', label: '发帖', unit: '次', min: 1 },
|
||||
{ key: 'rate_limit_comment', label: '评论', unit: '次', min: 1 },
|
||||
{ key: 'rate_limit_register', label: '注册', unit: '次', min: 1 },
|
||||
{ key: 'rate_limit_login', label: '登录', unit: '次', min: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
title: '内容长度',
|
||||
summary: '发帖与评论的字数上限,服务端强制校验',
|
||||
rows: [
|
||||
{ key: 'post_title_max', label: '帖子标题', unit: '字', min: 1 },
|
||||
{ key: 'post_tags_max', label: '帖子标签', unit: '字', hint: '0 = 不限', min: 0 },
|
||||
{ key: 'post_content_max', label: '帖子正文', unit: '字', hint: '0 = 不限', min: 0 },
|
||||
{ key: 'comment_max', label: '评论内容', unit: '字', min: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'search',
|
||||
title: '搜索与列表',
|
||||
summary: '关键词长度与帖子列表分页',
|
||||
rows: [
|
||||
{ key: 'search_keyword_min', label: '关键词最短', unit: '字', min: 0 },
|
||||
{ key: 'search_keyword_max', label: '关键词最长', unit: '字', min: 1 },
|
||||
{ key: 'page_size_default', label: '默认每页', unit: '条', min: 1 },
|
||||
{ key: 'page_size_max', label: '最大每页', unit: '条', min: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'user',
|
||||
title: '用户账号',
|
||||
summary: '注册、改密与头像上传限制',
|
||||
rows: [
|
||||
{ key: 'password_min_len', label: '密码最短', unit: '位', min: 4 },
|
||||
{ key: 'avatar_max_mb', label: '头像上限', unit: 'MB', min: 1 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: typeof SlidersHorizontal }[] = [
|
||||
{ id: 'limits', label: '论坛限制', icon: SlidersHorizontal },
|
||||
{ id: 'filter', label: '敏感词', icon: Shield },
|
||||
{ id: 'system', label: '系统维护', icon: Server },
|
||||
];
|
||||
|
||||
function SettingTable({
|
||||
sections,
|
||||
limits,
|
||||
onChange,
|
||||
}: {
|
||||
sections: SettingSection[];
|
||||
limits: ForumLimits;
|
||||
onChange: (key: keyof ForumLimits, value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="admin-settings-sections">
|
||||
{sections.map(section => (
|
||||
<section key={section.id} className="admin-settings-section" id={`settings-${section.id}`}>
|
||||
<div className="admin-settings-section-head">
|
||||
<h3>{section.title}</h3>
|
||||
<p>{section.summary}</p>
|
||||
</div>
|
||||
<div className="admin-settings-table" role="group" aria-label={section.title}>
|
||||
{section.rows.map(row => (
|
||||
<div key={row.key} className="admin-settings-row">
|
||||
<label htmlFor={`limit-${row.key}`} className="admin-settings-row-label">
|
||||
{row.label}
|
||||
</label>
|
||||
<div className="admin-settings-row-input">
|
||||
<Input
|
||||
id={`limit-${row.key}`}
|
||||
type="number"
|
||||
min={row.min ?? 0}
|
||||
value={limits[row.key]}
|
||||
onChange={e => onChange(row.key, e.target.value)}
|
||||
className="admin-settings-input"
|
||||
/>
|
||||
{row.unit && <span className="admin-settings-unit">{row.unit}</span>}
|
||||
</div>
|
||||
<span className="admin-settings-row-hint">{row.hint ?? ''}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
const { ready } = useAdminGuard();
|
||||
const [settings, setSettings] = useState<AdminSettings | null>(null);
|
||||
const [limits, setLimits] = useState<ForumLimits | null>(null);
|
||||
const [filterWords, setFilterWords] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<TabId>('limits');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [backing, setBacking] = useState(false);
|
||||
const [editWindowHours, setEditWindowHours] = useState('24');
|
||||
const [savingForum, setSavingForum] = useState(false);
|
||||
const [savingFilter, setSavingFilter] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
api.adminSettings()
|
||||
.then(s => {
|
||||
setSettings(s);
|
||||
setEditWindowHours(String(s.post_edit_window_hours ?? 24));
|
||||
setLimits(s.limits);
|
||||
setFilterWords(s.filter_words);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [ready]);
|
||||
|
||||
const handleLimitChange = (key: keyof ForumLimits, value: string) => {
|
||||
const n = parseInt(value, 10);
|
||||
if (Number.isNaN(n)) return;
|
||||
setLimits(prev => prev ? { ...prev, [key]: n } : prev);
|
||||
};
|
||||
|
||||
const handleSaveForumSettings = async () => {
|
||||
const hours = parseInt(editWindowHours, 10);
|
||||
if (Number.isNaN(hours) || hours < 0) {
|
||||
notify.warning('请输入有效的小时数(0 表示不限)');
|
||||
return;
|
||||
}
|
||||
if (!limits) return;
|
||||
setSavingForum(true);
|
||||
try {
|
||||
const r = await api.adminUpdateForumSettings({ post_edit_window_hours: hours });
|
||||
const r = await api.adminUpdateForumSettings(limits);
|
||||
notify.success(r.message);
|
||||
setSettings(s => s ? { ...s, post_edit_window_hours: r.post_edit_window_hours } : s);
|
||||
invalidateForumLimitsCache();
|
||||
setLimits(r.limits);
|
||||
setSettings(s => s ? { ...s, limits: r.limits } : s);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
@@ -44,6 +175,19 @@ export default function AdminSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveFilterWords = async () => {
|
||||
setSavingFilter(true);
|
||||
try {
|
||||
const r = await api.adminUpdateFilterWords(filterWords);
|
||||
notify.success(r.message);
|
||||
setSettings(s => s ? { ...s, filter_words: filterWords, filter_word_count: r.word_count } : s);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSavingFilter(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackup = async () => {
|
||||
setBacking(true);
|
||||
try {
|
||||
@@ -60,56 +204,121 @@ export default function AdminSettingsPage() {
|
||||
if (!ready || loading) {
|
||||
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
|
||||
}
|
||||
if (!settings) return null;
|
||||
if (!settings || !limits) return null;
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<div className="admin-settings-page">
|
||||
<header className="admin-page-head">
|
||||
<h1>系统设置</h1>
|
||||
<p>数据目录、敏感词配置与数据库备份</p>
|
||||
</div>
|
||||
<p>管理论坛运行规则、敏感词过滤与数据维护</p>
|
||||
</header>
|
||||
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-head">论坛规则</div>
|
||||
<p className="admin-card-desc">
|
||||
设置普通用户发布后可编辑帖子的时限。超过时限后仅管理员可编辑。设为 0 表示不限时。
|
||||
</p>
|
||||
<div className="admin-form-row">
|
||||
<label htmlFor="edit-window-hours">编辑时限(小时)</label>
|
||||
<Input
|
||||
id="edit-window-hours"
|
||||
type="number"
|
||||
min={0}
|
||||
value={editWindowHours}
|
||||
onChange={e => setEditWindowHours(e.target.value)}
|
||||
className="max-w-[120px]"
|
||||
/>
|
||||
<Button onClick={handleSaveForumSettings} loading={savingForum}>
|
||||
保存
|
||||
</Button>
|
||||
<nav className="admin-settings-tabs" aria-label="设置分类">
|
||||
{TABS.map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`admin-settings-tab${activeTab === id ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab(id)}
|
||||
aria-selected={activeTab === id}
|
||||
>
|
||||
<Icon size={15} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{activeTab === 'limits' && (
|
||||
<div className="admin-settings-panel">
|
||||
<div className="admin-card admin-settings-card">
|
||||
<div className="admin-card-head">
|
||||
<span>论坛限制</span>
|
||||
<span className="admin-settings-card-badge">共 {SETTING_SECTIONS.length} 组</span>
|
||||
</div>
|
||||
<div className="admin-card-body">
|
||||
<SettingTable sections={SETTING_SECTIONS} limits={limits} onChange={handleLimitChange} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-settings-bar">
|
||||
<p>修改后请点击保存,新规则立即对全部用户生效</p>
|
||||
<Button onClick={handleSaveForumSettings} loading={savingForum}>
|
||||
保存论坛限制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-head">运行信息</div>
|
||||
<dl className="admin-dl">
|
||||
<dt>数据目录</dt><dd><code>{settings.data_dir}</code></dd>
|
||||
<dt>数据库路径</dt><dd><code>{settings.db_path}</code></dd>
|
||||
<dt>敏感词配置</dt><dd><code>{settings.filter_path}</code></dd>
|
||||
<dt>监听端口</dt><dd>{settings.port}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
{activeTab === 'filter' && (
|
||||
<div className="admin-settings-panel">
|
||||
<div className="admin-card admin-settings-card">
|
||||
<div className="admin-card-head">
|
||||
<span>敏感词过滤</span>
|
||||
<span className="admin-settings-card-badge">{settings.filter_word_count} 个词条</span>
|
||||
</div>
|
||||
<div className="admin-card-body admin-settings-filter-body">
|
||||
<p className="admin-settings-filter-tip">
|
||||
每行一个词,<code>#</code> 开头为注释。保存后写入 <code>filter_words.txt</code> 并立即生效。
|
||||
</p>
|
||||
<Textarea
|
||||
rows={16}
|
||||
value={filterWords}
|
||||
onChange={e => setFilterWords(e.target.value)}
|
||||
className="admin-settings-filter-textarea"
|
||||
spellCheck={false}
|
||||
placeholder={'# 示例\n违禁词\n广告刷单'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-settings-bar">
|
||||
<p>敏感词会在发帖、评论、昵称等文本中自动替换为 *</p>
|
||||
<Button onClick={handleSaveFilterWords} loading={savingFilter}>
|
||||
保存敏感词
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-head">数据备份</div>
|
||||
<p className="admin-card-desc">
|
||||
导出当前 SQLite 数据库副本,文件名格式为 <code>jiang13_backup_YYYYMMDD_HHMMSS.db</code>
|
||||
</p>
|
||||
<Button onClick={handleBackup} loading={backing}>
|
||||
<Database size={16} />
|
||||
立即备份并下载
|
||||
</Button>
|
||||
</div>
|
||||
{activeTab === 'system' && (
|
||||
<div className="admin-settings-panel">
|
||||
<div className="admin-card admin-settings-card">
|
||||
<div className="admin-card-head">运行信息</div>
|
||||
<div className="admin-card-body">
|
||||
<dl className="admin-settings-info">
|
||||
<div className="admin-settings-info-row">
|
||||
<dt>数据目录</dt>
|
||||
<dd><code>{settings.data_dir}</code></dd>
|
||||
</div>
|
||||
<div className="admin-settings-info-row">
|
||||
<dt>数据库</dt>
|
||||
<dd><code>{settings.db_path}</code></dd>
|
||||
</div>
|
||||
<div className="admin-settings-info-row">
|
||||
<dt>敏感词文件</dt>
|
||||
<dd><code>{settings.filter_path}</code></dd>
|
||||
</div>
|
||||
<div className="admin-settings-info-row">
|
||||
<dt>监听端口</dt>
|
||||
<dd>{settings.port}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card admin-settings-card">
|
||||
<div className="admin-card-head">数据备份</div>
|
||||
<div className="admin-card-body admin-settings-backup-body">
|
||||
<p>导出当前 SQLite 数据库副本,便于迁移或灾难恢复。</p>
|
||||
<p className="admin-settings-backup-name">
|
||||
文件名:<code>jiang13_backup_YYYYMMDD_HHMMSS.db</code>
|
||||
</p>
|
||||
<Button onClick={handleBackup} loading={backing}>
|
||||
<Database size={16} />
|
||||
立即备份并下载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2988,10 +2988,10 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
display: inline-flex; align-items: center; gap: 6px; font-size: 13px;
|
||||
color: var(--j13-green); background: none; border: none; cursor: pointer;
|
||||
}
|
||||
.admin-body { display: flex; min-height: calc(100vh - 56px); }
|
||||
.admin-body { display: flex; height: calc(100vh - 56px); overflow: hidden; }
|
||||
.admin-sidebar {
|
||||
width: 200px; flex-shrink: 0; padding: 16px 10px; border-right: 1px solid var(--j13-border);
|
||||
background: hsl(var(--card));
|
||||
background: hsl(var(--card)); overflow-y: auto;
|
||||
}
|
||||
.admin-nav-item {
|
||||
display: flex; align-items: center; gap: 8px; width: 100%;
|
||||
@@ -3000,7 +3000,7 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
}
|
||||
.admin-nav-item:hover { background: var(--j13-green-bg); color: var(--j13-green); }
|
||||
.admin-nav-item.active { background: var(--j13-green-bg); color: var(--j13-green); font-weight: 600; }
|
||||
.admin-main { flex: 1; padding: 24px; overflow: auto; }
|
||||
.admin-main { flex: 1; min-width: 0; min-height: 0; padding: 24px; overflow-y: auto; }
|
||||
.admin-page-head { margin-bottom: 20px; }
|
||||
.admin-page-head h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
|
||||
.admin-page-head p { font-size: 13px; color: hsl(var(--muted-foreground)); }
|
||||
@@ -3015,13 +3015,14 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
.admin-stat-label { font-size: 12px; color: hsl(var(--muted-foreground)); margin-top: 4px; }
|
||||
.admin-card {
|
||||
border: 1px solid var(--j13-border); border-radius: 10px; background: hsl(var(--card));
|
||||
overflow: hidden; margin-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.admin-card-body { padding: 16px 20px 20px; }
|
||||
.admin-card-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 16px; border-bottom: 1px solid var(--j13-border); font-weight: 600; font-size: 14px;
|
||||
}
|
||||
.admin-card-desc { padding: 12px 16px 0; font-size: 13px; color: hsl(var(--muted-foreground)); }
|
||||
.admin-card-desc { padding: 12px 16px 0; font-size: 13px; color: hsl(var(--muted-foreground)); line-height: 1.5; }
|
||||
.admin-card .admin-card-desc + button { margin: 12px 16px 16px; }
|
||||
.admin-form-row {
|
||||
display: flex;
|
||||
@@ -3032,6 +3033,189 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
font-size: 13px;
|
||||
}
|
||||
.admin-form-row label { color: hsl(var(--muted-foreground)); white-space: nowrap; }
|
||||
|
||||
/* ========== 系统设置页 ========== */
|
||||
.admin-settings-page {
|
||||
max-width: 880px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
.admin-settings-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 0;
|
||||
border-bottom: 1px solid var(--j13-border);
|
||||
}
|
||||
.admin-settings-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: -1px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
transition: color .15s, border-color .15s;
|
||||
}
|
||||
.admin-settings-tab:hover { color: hsl(var(--foreground)); }
|
||||
.admin-settings-tab.active {
|
||||
color: var(--j13-green);
|
||||
border-bottom-color: var(--j13-green);
|
||||
}
|
||||
.admin-settings-panel { display: flex; flex-direction: column; gap: 0; }
|
||||
.admin-settings-card { margin-bottom: 0; }
|
||||
.admin-settings-card-badge {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--muted) / 0.45);
|
||||
}
|
||||
.admin-settings-sections { display: flex; flex-direction: column; gap: 20px; }
|
||||
.admin-settings-section-head h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.admin-settings-section-head p {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.45;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.admin-settings-table {
|
||||
border: 1px solid var(--j13-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
.admin-settings-row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 140px 1fr;
|
||||
align-items: center;
|
||||
gap: 8px 16px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--j13-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.admin-settings-row:last-child { border-bottom: none; }
|
||||
.admin-settings-row-label {
|
||||
color: hsl(var(--foreground));
|
||||
font-weight: 500;
|
||||
}
|
||||
.admin-settings-row-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.admin-settings-input {
|
||||
width: 88px;
|
||||
height: 32px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.admin-settings-unit {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
white-space: nowrap;
|
||||
}
|
||||
.admin-settings-row-hint {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.admin-settings-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--j13-border);
|
||||
border-radius: 10px;
|
||||
background: hsl(var(--card));
|
||||
}
|
||||
.admin-settings-bar p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.45;
|
||||
max-width: 420px;
|
||||
}
|
||||
.admin-settings-filter-body { display: flex; flex-direction: column; gap: 12px; }
|
||||
.admin-settings-filter-tip {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.5;
|
||||
}
|
||||
.admin-settings-filter-tip code,
|
||||
.admin-settings-backup-name code {
|
||||
font-size: 11px;
|
||||
background: hsl(var(--muted));
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.admin-settings-filter-textarea {
|
||||
min-height: 320px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
resize: vertical;
|
||||
}
|
||||
.admin-settings-info { display: flex; flex-direction: column; gap: 0; }
|
||||
.admin-settings-info-row {
|
||||
display: grid;
|
||||
grid-template-columns: 88px 1fr;
|
||||
gap: 12px 16px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--j13-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.admin-settings-info-row:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.admin-settings-info-row:first-child { padding-top: 0; }
|
||||
.admin-settings-info-row dt { color: hsl(var(--muted-foreground)); }
|
||||
.admin-settings-info-row dd { margin: 0; word-break: break-all; }
|
||||
.admin-settings-info-row dd code {
|
||||
font-size: 12px;
|
||||
background: hsl(var(--muted));
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.admin-settings-backup-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
.admin-settings-backup-body > p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.5;
|
||||
}
|
||||
.admin-settings-backup-name { font-size: 12px !important; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.admin-settings-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.admin-settings-row-hint { padding-left: 0; }
|
||||
.admin-settings-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.admin-settings-bar p { max-width: none; }
|
||||
.admin-settings-info-row { grid-template-columns: 1fr; gap: 4px; }
|
||||
}
|
||||
.admin-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.admin-table th, .admin-table td { padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--j13-border); }
|
||||
.admin-table th { font-weight: 600; color: hsl(var(--muted-foreground)); background: hsl(var(--muted) / 0.3); }
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
|
||||
|
||||
marked.setOptions({ breaks: true, gfm: true });
|
||||
|
||||
const MEMBERS_BLOCK_RE = /:::members[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*:::/g;
|
||||
|
||||
type MdPart = { type: 'text' | 'members'; content: string };
|
||||
|
||||
/** 拆分普通 Markdown 与 :::members 区块 */
|
||||
function splitMembersBlocks(md: string): MdPart[] {
|
||||
const parts: MdPart[] = [];
|
||||
let lastIndex = 0;
|
||||
const re = new RegExp(MEMBERS_BLOCK_RE.source, 'g');
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = re.exec(md)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ type: 'text', content: md.slice(lastIndex, match.index) });
|
||||
}
|
||||
parts.push({ type: 'members', content: match[1] });
|
||||
lastIndex = re.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < md.length) {
|
||||
parts.push({ type: 'text', content: md.slice(lastIndex) });
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
parts.push({ type: 'text', content: md });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Markdown 转 HTML,用于预览与发布 */
|
||||
export function markdownToHtml(md: string): string {
|
||||
const parts = splitMembersBlocks(md);
|
||||
const html = parts.map(part => {
|
||||
if (part.type === 'members') {
|
||||
const inner = marked.parse(part.content.trim()) as string;
|
||||
return `<members-only>${inner}</members-only>`;
|
||||
}
|
||||
return marked.parse(part.content) as string;
|
||||
}).join('');
|
||||
|
||||
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
|
||||
}
|
||||
|
||||
/** HTML 转 Markdown,用于编辑已有帖子时回填编辑器 */
|
||||
export function htmlToMarkdown(html: string): string {
|
||||
if (!html.trim()) return '';
|
||||
|
||||
const doc = new DOMParser().parseFromString(
|
||||
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
|
||||
'text/html',
|
||||
);
|
||||
|
||||
const walk = (node: Node): string => {
|
||||
if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? '';
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
||||
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const inner = () => Array.from(el.childNodes).map(walk).join('');
|
||||
|
||||
switch (tag) {
|
||||
case 'members-only': {
|
||||
if (el.getAttribute('data-locked') === 'true') return '';
|
||||
const body = el.querySelector('.post-members-only__body');
|
||||
const content = body ? Array.from(body.childNodes).map(walk).join('') : inner();
|
||||
return `:::members\n${content.trim()}\n:::\n\n`;
|
||||
}
|
||||
case 'br': return '\n';
|
||||
case 'p': return inner().trimEnd() + '\n\n';
|
||||
case 'h1': return `# ${inner().trim()}\n\n`;
|
||||
case 'h2': return `## ${inner().trim()}\n\n`;
|
||||
case 'h3': return `### ${inner().trim()}\n\n`;
|
||||
case 'h4': return `#### ${inner().trim()}\n\n`;
|
||||
case 'h5': return `##### ${inner().trim()}\n\n`;
|
||||
case 'h6': return `###### ${inner().trim()}\n\n`;
|
||||
case 'strong':
|
||||
case 'b': return `**${inner()}**`;
|
||||
case 'em':
|
||||
case 'i': return `*${inner()}*`;
|
||||
case 'code': return `\`${inner()}\``;
|
||||
case 'pre': return `\`\`\`\n${el.textContent ?? ''}\n\`\`\`\n\n`;
|
||||
case 'a': return `[${inner()}](${el.getAttribute('href') ?? ''})`;
|
||||
case 'img': return ` ?? ''})`;
|
||||
case 'ul':
|
||||
return Array.from(el.children)
|
||||
.map(li => `- ${walk(li).trim()}`)
|
||||
.join('\n') + '\n\n';
|
||||
case 'ol':
|
||||
return Array.from(el.children)
|
||||
.map((li, i) => `${i + 1}. ${walk(li).trim()}`)
|
||||
.join('\n') + '\n\n';
|
||||
case 'li': return inner();
|
||||
case 'blockquote': {
|
||||
const text = inner().trim().replace(/\n/g, '\n> ');
|
||||
return `> ${text}\n\n`;
|
||||
}
|
||||
case 'hr': return '---\n\n';
|
||||
case 'div':
|
||||
if (el.classList.contains('post-members-only__badge')
|
||||
|| el.classList.contains('post-members-only__gate')
|
||||
|| el.classList.contains('post-members-only__gate-icon')) {
|
||||
return '';
|
||||
}
|
||||
if (el.classList.contains('post-members-only__body')) {
|
||||
return inner();
|
||||
}
|
||||
return inner();
|
||||
default: return inner();
|
||||
}
|
||||
};
|
||||
|
||||
return walk(doc.body).replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
/** 统计正文字数(不含空白) */
|
||||
export function countWords(text: string): number {
|
||||
const t = text.trim();
|
||||
if (!t) return 0;
|
||||
const cjk = t.match(/[\u4e00-\u9fff]/g)?.length ?? 0;
|
||||
const en = t.match(/[a-zA-Z0-9]+/g)?.length ?? 0;
|
||||
return cjk + en;
|
||||
}
|
||||
|
||||
/** 编辑器插入用的会员专属区块模板 */
|
||||
export const MEMBERS_ONLY_TEMPLATE = ':::members\n在此输入仅登录用户可见的内容…\n:::';
|
||||
8
frontend/src/utils/text.ts
Normal file
8
frontend/src/utils/text.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** 统计正文字数(CJK 按字、英文按词) */
|
||||
export function countWords(text: string): number {
|
||||
const t = text.trim();
|
||||
if (!t) return 0;
|
||||
const cjk = t.match(/[\u4e00-\u9fff]/g)?.length ?? 0;
|
||||
const en = t.match(/[a-zA-Z0-9]+/g)?.length ?? 0;
|
||||
return cjk + en;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export default defineConfig({
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (!id.includes('node_modules')) return;
|
||||
if (id.includes('marked') || id.includes('dompurify')) return 'markdown-vendor';
|
||||
if (id.includes('dompurify')) return 'purify-vendor';
|
||||
if (id.includes('@tanstack/react-virtual')) return 'virtual-vendor';
|
||||
if (id.includes('@radix-ui') || id.includes('lucide-react')) return 'ui-vendor';
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user