完善论坛配置与发帖体验:TipTap 富文本、图片上传、修订历史、Feed 排序与后台参数管理。

同步更新 README 与 ROADMAP,反映最新功能与开发状态。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-16 04:11:38 +08:00
parent 1d273066b0
commit b451703642
73 changed files with 1943 additions and 752 deletions

View File

@@ -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">

View File

@@ -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);

View File

@@ -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>

View File

@@ -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>}

View File

@@ -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>
);
}