Compare commits
2 Commits
6b0a1d4281
...
bb8d415eb7
| Author | SHA1 | Date | |
|---|---|---|---|
| bb8d415eb7 | |||
| e6e7ed73e3 |
@@ -215,6 +215,22 @@ export const api = {
|
|||||||
method: 'POST', body: JSON.stringify({ reason: reason || '' }),
|
method: 'POST', body: JSON.stringify({ reason: reason || '' }),
|
||||||
}),
|
}),
|
||||||
adminDeleteComment: (id: number) => request(`/api/admin/comments/${id}`, { method: 'DELETE' }),
|
adminDeleteComment: (id: number) => request(`/api/admin/comments/${id}`, { method: 'DELETE' }),
|
||||||
|
adminTrashComments: (params?: { page?: number; keyword?: string }) => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params?.page) q.set('page', String(params.page));
|
||||||
|
if (params?.keyword) q.set('keyword', params.keyword);
|
||||||
|
const qs = q.toString();
|
||||||
|
return request<{
|
||||||
|
comments: (Comment & { deleted_at: string })[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
total_pages: number;
|
||||||
|
}>(`/api/admin/comments/trash${qs ? `?${qs}` : ''}`);
|
||||||
|
},
|
||||||
|
adminRestoreComment: (id: number) =>
|
||||||
|
request<{ message: string }>(`/api/admin/comments/${id}/restore`, { method: 'POST' }),
|
||||||
|
adminPurgeComment: (id: number) =>
|
||||||
|
request<{ message: string }>(`/api/admin/comments/${id}/purge`, { method: 'DELETE' }),
|
||||||
adminCommentRevisions: (id: number) =>
|
adminCommentRevisions: (id: number) =>
|
||||||
request<{ revisions: CommentRevision[] }>(`/api/admin/comments/${id}/revisions`),
|
request<{ revisions: CommentRevision[] }>(`/api/admin/comments/${id}/revisions`),
|
||||||
adminUsers: (page = 1, opts?: { keyword?: string; filter?: string }) => {
|
adminUsers: (page = 1, opts?: { keyword?: string; filter?: string }) => {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
formatFenceInfo,
|
formatFenceInfo,
|
||||||
type CodeBlockInsertOptions,
|
type CodeBlockInsertOptions,
|
||||||
} from '../utils/codeBlockOptions';
|
} from '../utils/codeBlockOptions';
|
||||||
|
import { fenceLengthForContent } from '../utils/markdownFences';
|
||||||
import { Tooltip } from './ui/Tooltip';
|
import { Tooltip } from './ui/Tooltip';
|
||||||
|
|
||||||
export interface ArticleEditorHandle {
|
export interface ArticleEditorHandle {
|
||||||
@@ -85,8 +86,9 @@ const REPLY_ONLY_PLACEHOLDER = '在此输入回复后可见的内容…';
|
|||||||
/** 按选项生成 Markdown 侧插入片段(围栏 meta,便于手写) */
|
/** 按选项生成 Markdown 侧插入片段(围栏 meta,便于手写) */
|
||||||
function buildMarkdownCodeBlockSnippet(opts: CodeBlockInsertOptions, body = '代码'): string {
|
function buildMarkdownCodeBlockSnippet(opts: CodeBlockInsertOptions, body = '代码'): string {
|
||||||
const info = formatFenceInfo(opts);
|
const info = formatFenceInfo(opts);
|
||||||
const fence = info ? `\`\`\`${info}` : '```';
|
const fence = '`'.repeat(fenceLengthForContent(body));
|
||||||
return `\n${fence}\n${body}\n\`\`\`\n`;
|
const open = info ? `${fence}${info}` : fence;
|
||||||
|
return `\n${open}\n${body}\n${fence}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 生成 GFM 管道表;源码侧始终带表头分隔行 */
|
/** 生成 GFM 管道表;源码侧始终带表头分隔行 */
|
||||||
|
|||||||
@@ -368,7 +368,9 @@ function CommentItem({
|
|||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>确定删除该评论?</AlertDialogTitle>
|
<AlertDialogTitle>确定删除该评论?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>删除后不可恢复。</AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
|
将同时移入回收站其下所有回复,可在后台恢复或永久删除。
|
||||||
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import { Pin } from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
className?: string;
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 置顶图钉标识 */
|
|
||||||
export default function PinnedIcon({ className, size = 16 }: Props) {
|
|
||||||
return (
|
|
||||||
<Pin
|
|
||||||
className={cn('post-pinned-icon', className)}
|
|
||||||
size={size}
|
|
||||||
fill="currentColor"
|
|
||||||
aria-label="置顶"
|
|
||||||
role="img"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import { memo } from 'react';
|
|||||||
import { Eye, Image as ImageIcon, MessageCircle, ThumbsUp } from 'lucide-react';
|
import { Eye, Image as ImageIcon, MessageCircle, ThumbsUp } from 'lucide-react';
|
||||||
import BoardBadge from '@/components/BoardBadge';
|
import BoardBadge from '@/components/BoardBadge';
|
||||||
import FeaturedIcon from '@/components/FeaturedIcon';
|
import FeaturedIcon from '@/components/FeaturedIcon';
|
||||||
import PinnedIcon from '@/components/PinnedIcon';
|
|
||||||
import UserLink from '@/components/UserLink';
|
import UserLink from '@/components/UserLink';
|
||||||
import type { PostItem } from '../api/types';
|
import type { PostItem } from '../api/types';
|
||||||
import type { FeedSort } from './FeedSortBar';
|
import type { FeedSort } from './FeedSortBar';
|
||||||
@@ -79,9 +78,7 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
|
|||||||
|
|
||||||
<div className="post-title-row">
|
<div className="post-title-row">
|
||||||
{post.pinned && (
|
{post.pinned && (
|
||||||
<span className="post-pin-badge post-pin-badge--icon" title="置顶">
|
<span className="post-pin-badge" title="置顶">置顶</span>
|
||||||
<PinnedIcon size={13} />
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
{post.featured && (
|
{post.featured && (
|
||||||
<span className="post-feature-badge" title="精华">
|
<span className="post-feature-badge" title="精华">
|
||||||
|
|||||||
@@ -7,12 +7,19 @@ const REFRESH_THRESHOLD = 68;
|
|||||||
const PULL_MAX = 108;
|
const PULL_MAX = 108;
|
||||||
/** 判定为「下拉」意图的最小位移,避免误触滚动 */
|
/** 判定为「下拉」意图的最小位移,避免误触滚动 */
|
||||||
const ARM_DELTA = 10;
|
const ARM_DELTA = 10;
|
||||||
|
/** 指示器休息位在视口上方的隐藏量,下拉时再滑入 */
|
||||||
|
const INDICATOR_HIDE = 40;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 定位当前真正滚动的容器。
|
* 定位当前真正滚动的容器。
|
||||||
* SPA 使用内部滚动(body overflow:hidden),原生下拉刷新不可用,需挂到此容器。
|
* SPA 使用内部滚动(body overflow:hidden),原生下拉刷新不可用,需挂到此容器。
|
||||||
|
* 发帖/编辑页不参与:主栏 overflow:hidden,滚动在编辑器内层,绑 PTR 会误触并打断编辑。
|
||||||
*/
|
*/
|
||||||
function pickScrollEl(): HTMLElement | null {
|
function pickScrollEl(): HTMLElement | null {
|
||||||
|
if (document.querySelector('.main-content--compose, .compose-page')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// 手机 Feed 整栏滚动(板块 / 排序栏可滚走)
|
// 手机 Feed 整栏滚动(板块 / 排序栏可滚走)
|
||||||
const mobileFeed = document.querySelector<HTMLElement>('.main-content--feed-mobile-scroll');
|
const mobileFeed = document.querySelector<HTMLElement>('.main-content--feed-mobile-scroll');
|
||||||
if (mobileFeed) return mobileFeed;
|
if (mobileFeed) return mobileFeed;
|
||||||
@@ -23,9 +30,6 @@ function pickScrollEl(): HTMLElement | null {
|
|||||||
const page = document.querySelector<HTMLElement>('.page-wrap:not(.page-wrap--feed)');
|
const page = document.querySelector<HTMLElement>('.page-wrap:not(.page-wrap--feed)');
|
||||||
if (page) return page;
|
if (page) return page;
|
||||||
|
|
||||||
const compose = document.querySelector<HTMLElement>('.main-content--compose');
|
|
||||||
if (compose) return compose;
|
|
||||||
|
|
||||||
const admin = document.querySelector<HTMLElement>('.admin-main');
|
const admin = document.querySelector<HTMLElement>('.admin-main');
|
||||||
if (admin) return admin;
|
if (admin) return admin;
|
||||||
|
|
||||||
@@ -40,6 +44,37 @@ function isTouchDevice(): boolean {
|
|||||||
|| navigator.maxTouchPoints > 0;
|
|| navigator.maxTouchPoints > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 输入框 / 富文本编辑中不触发下拉刷新 */
|
||||||
|
function isEditableTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof Element)) return false;
|
||||||
|
return Boolean(
|
||||||
|
target.closest(
|
||||||
|
'textarea, input, select, [contenteditable="true"], .ProseMirror, .article-editor, .compose-page',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触摸落在内层可滚动区域且该区域未到顶时,交给内层滚动,不武装 PTR。
|
||||||
|
*/
|
||||||
|
function isNestedScrollBlocking(target: EventTarget | null, bound: HTMLElement): boolean {
|
||||||
|
let node: Element | null = target instanceof Element ? target : null;
|
||||||
|
while (node && node !== bound) {
|
||||||
|
if (node instanceof HTMLElement) {
|
||||||
|
const { overflowY } = getComputedStyle(node);
|
||||||
|
if (
|
||||||
|
(overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay')
|
||||||
|
&& node.scrollHeight > node.clientHeight + 1
|
||||||
|
&& node.scrollTop > 1
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 手机端下拉刷新:在内部滚动容器顶部下拉后整页重载。
|
* 手机端下拉刷新:在内部滚动容器顶部下拉后整页重载。
|
||||||
* (浏览器原生 PTR 依赖 document 滚动,与本站 app-shell 布局不兼容。)
|
* (浏览器原生 PTR 依赖 document 滚动,与本站 app-shell 布局不兼容。)
|
||||||
@@ -49,6 +84,7 @@ function isTouchDevice(): boolean {
|
|||||||
export default function PullToRefresh() {
|
export default function PullToRefresh() {
|
||||||
const [pull, setPull] = useState(0);
|
const [pull, setPull] = useState(0);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [settling, setSettling] = useState(false);
|
||||||
const pullRef = useRef(0);
|
const pullRef = useRef(0);
|
||||||
const refreshingRef = useRef(false);
|
const refreshingRef = useRef(false);
|
||||||
const startYRef = useRef(0);
|
const startYRef = useRef(0);
|
||||||
@@ -75,7 +111,10 @@ export default function PullToRefresh() {
|
|||||||
trackingRef.current = false;
|
trackingRef.current = false;
|
||||||
pullingRef.current = false;
|
pullingRef.current = false;
|
||||||
startYRef.current = 0;
|
startYRef.current = 0;
|
||||||
if (!refreshingRef.current) setPull(0);
|
if (!refreshingRef.current) {
|
||||||
|
setSettling(false);
|
||||||
|
setPull(0);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTouchStart = (e: TouchEvent) => {
|
const onTouchStart = (e: TouchEvent) => {
|
||||||
@@ -87,8 +126,11 @@ export default function PullToRefresh() {
|
|||||||
)) {
|
)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isEditableTarget(e.target)) return;
|
||||||
|
if (isNestedScrollBlocking(e.target, el)) return;
|
||||||
trackingRef.current = true;
|
trackingRef.current = true;
|
||||||
pullingRef.current = false;
|
pullingRef.current = false;
|
||||||
|
setSettling(false);
|
||||||
startYRef.current = e.touches[0].clientY;
|
startYRef.current = e.touches[0].clientY;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -109,6 +151,7 @@ export default function PullToRefresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pullingRef.current = true;
|
pullingRef.current = true;
|
||||||
|
setSettling(false);
|
||||||
setPull(Math.min(PULL_MAX, dy * 0.55));
|
setPull(Math.min(PULL_MAX, dy * 0.55));
|
||||||
if (e.cancelable) e.preventDefault();
|
if (e.cancelable) e.preventDefault();
|
||||||
};
|
};
|
||||||
@@ -121,12 +164,14 @@ export default function PullToRefresh() {
|
|||||||
|
|
||||||
if (shouldRefresh) {
|
if (shouldRefresh) {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
|
setSettling(true);
|
||||||
setPull(REFRESH_THRESHOLD * 0.7);
|
setPull(REFRESH_THRESHOLD * 0.7);
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, 180);
|
}, 180);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setSettling(true);
|
||||||
setPull(0);
|
setPull(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -153,7 +198,17 @@ export default function PullToRefresh() {
|
|||||||
const tryBind = () => {
|
const tryBind = () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const next = pickScrollEl();
|
const next = pickScrollEl();
|
||||||
if (next) bind(next);
|
if (next) {
|
||||||
|
bind(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 发帖页等:卸掉旧绑定并收起指示器,避免残留气泡 / 误触
|
||||||
|
if (!bound && !scrollElRef.current && pullRef.current <= 0 && !trackingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unbind();
|
||||||
|
scrollElRef.current = null;
|
||||||
|
resetGesture();
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleBind = () => {
|
const scheduleBind = () => {
|
||||||
@@ -187,8 +242,13 @@ export default function PullToRefresh() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`ptr-indicator${ready ? ' ptr-indicator--ready' : ''}${refreshing ? ' ptr-indicator--refreshing' : ''}`}
|
className={[
|
||||||
style={{ transform: `translateY(${offset}px)` }}
|
'ptr-indicator',
|
||||||
|
ready ? 'ptr-indicator--ready' : '',
|
||||||
|
refreshing ? 'ptr-indicator--refreshing' : '',
|
||||||
|
settling ? 'ptr-indicator--settling' : '',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
style={{ transform: `translateY(${offset - INDICATOR_HIDE}px)` }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
<div className="ptr-indicator__chip">
|
<div className="ptr-indicator__chip">
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react
|
|||||||
import { useParams, useNavigate, useOutletContext, useLocation } from 'react-router-dom';
|
import { useParams, useNavigate, useOutletContext, useLocation } from 'react-router-dom';
|
||||||
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, Trash2, Sparkles, Flag, Ban, CircleCheck, CircleHelp, MoreHorizontal } from 'lucide-react';
|
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, Trash2, Sparkles, Flag, Ban, CircleCheck, CircleHelp, MoreHorizontal } from 'lucide-react';
|
||||||
import FeaturedIcon from '@/components/FeaturedIcon';
|
import FeaturedIcon from '@/components/FeaturedIcon';
|
||||||
import PinnedIcon from '@/components/PinnedIcon';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import BoardBadge from '@/components/BoardBadge';
|
import BoardBadge from '@/components/BoardBadge';
|
||||||
@@ -46,6 +45,7 @@ import { useAuth } from '../hooks/useAuth';
|
|||||||
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
||||||
import { getCachedSiteBranding } from '../hooks/useSiteBranding';
|
import { getCachedSiteBranding } from '../hooks/useSiteBranding';
|
||||||
import { formatDateTime, isTimeDiffSignificant } from '../utils/content';
|
import { formatDateTime, isTimeDiffSignificant } from '../utils/content';
|
||||||
|
import { collectCommentSubtreeIds } from '../utils/comment';
|
||||||
import { loadMyCommentIds } from '../utils/guest';
|
import { loadMyCommentIds } from '../utils/guest';
|
||||||
import { clearAllFeedCache } from '../utils/feedCache';
|
import { clearAllFeedCache } from '../utils/feedCache';
|
||||||
import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll';
|
import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll';
|
||||||
@@ -396,10 +396,11 @@ export default function PostDetailPage() {
|
|||||||
const handleDeleteComment = async (comment: Comment) => {
|
const handleDeleteComment = async (comment: Comment) => {
|
||||||
try {
|
try {
|
||||||
await api.deleteComment(comment.id);
|
await api.deleteComment(comment.id);
|
||||||
setComments(list => list.filter(c => c.id !== comment.id));
|
const removeIds = collectCommentSubtreeIds(comments, comment.id);
|
||||||
if (replyTo?.id === comment.id) setReplyTo(null);
|
setComments(list => list.filter(c => !removeIds.has(c.id)));
|
||||||
if (editingCommentId === comment.id) setEditingCommentId(null);
|
if (replyTo && removeIds.has(replyTo.id)) setReplyTo(null);
|
||||||
notify.success('评论已删除');
|
if (editingCommentId != null && removeIds.has(editingCommentId)) setEditingCommentId(null);
|
||||||
|
notify.success('评论已移入回收站');
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
notify.error(e instanceof Error ? e.message : '删除失败');
|
notify.error(e instanceof Error ? e.message : '删除失败');
|
||||||
throw e;
|
throw e;
|
||||||
@@ -600,9 +601,7 @@ export default function PostDetailPage() {
|
|||||||
<div className="post-detail-head">
|
<div className="post-detail-head">
|
||||||
<h1 className="post-detail-title">
|
<h1 className="post-detail-title">
|
||||||
{post.pinned && (
|
{post.pinned && (
|
||||||
<span className="post-pin-badge post-pin-badge--icon post-pin-badge--detail" title="置顶">
|
<span className="post-pin-badge post-pin-badge--detail" title="置顶">置顶</span>
|
||||||
<PinnedIcon size={16} />
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
{post.featured && <FeaturedIcon className="mr-2" size={18} />}
|
{post.featured && <FeaturedIcon className="mr-2" size={18} />}
|
||||||
{post.status === 'pending' && <Badge variant="orange" className="mr-2 align-middle">审核中</Badge>}
|
{post.status === 'pending' && <Badge variant="orange" className="mr-2 align-middle">审核中</Badge>}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Camera,
|
Camera,
|
||||||
Check,
|
Check,
|
||||||
|
Coins,
|
||||||
Copy,
|
Copy,
|
||||||
FileText,
|
FileText,
|
||||||
Hash,
|
Hash,
|
||||||
@@ -22,7 +23,7 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Label } from '@/components/ui/label';
|
||||||
import UserBadges from '../components/UserBadges';
|
import UserBadges from '../components/UserBadges';
|
||||||
import PointsWalletPanel from '../components/PointsWalletPanel';
|
import PointsWalletPanel from '../components/PointsWalletPanel';
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||||
@@ -63,10 +64,10 @@ const pwdSchema = (minLen: number) => z.object({
|
|||||||
type NickValues = z.infer<typeof nickSchema>;
|
type NickValues = z.infer<typeof nickSchema>;
|
||||||
type SigValues = z.infer<ReturnType<typeof sigSchema>>;
|
type SigValues = z.infer<ReturnType<typeof sigSchema>>;
|
||||||
type PwdValues = z.infer<ReturnType<typeof pwdSchema>>;
|
type PwdValues = z.infer<ReturnType<typeof pwdSchema>>;
|
||||||
type ProfileTab = 'posts' | 'settings' | 'security';
|
type ProfileTab = 'posts' | 'points' | 'settings' | 'security';
|
||||||
|
|
||||||
function parseTab(raw: string | null): ProfileTab {
|
function parseTab(raw: string | null): ProfileTab {
|
||||||
if (raw === 'settings' || raw === 'security' || raw === 'posts') return raw;
|
if (raw === 'settings' || raw === 'security' || raw === 'points' || raw === 'posts') return raw;
|
||||||
return 'posts';
|
return 'posts';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +97,8 @@ export default function ProfilePage() {
|
|||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const dragCounter = useRef(0);
|
const dragCounter = useRef(0);
|
||||||
const copyTimer = useRef<ReturnType<typeof setTimeout>>();
|
const copyTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
const sigSectionRef = useRef<HTMLDivElement>(null);
|
||||||
|
const pendingFocusSig = useRef(false);
|
||||||
|
|
||||||
const { limits } = useForumLimits();
|
const { limits } = useForumLimits();
|
||||||
const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20;
|
const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20;
|
||||||
@@ -124,6 +127,38 @@ export default function ProfilePage() {
|
|||||||
setParams(nextParams, { replace: true });
|
setParams(nextParams, { replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const focusSignatureField = useCallback(() => {
|
||||||
|
sigSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
window.setTimeout(() => {
|
||||||
|
sigSectionRef.current?.querySelector('textarea')?.focus();
|
||||||
|
}, 80);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 顶部签名 /「编辑资料」:跳到资料 Tab 并聚焦签名框 */
|
||||||
|
const goEditSignature = () => {
|
||||||
|
if (tab !== 'settings') {
|
||||||
|
pendingFocusSig.current = true;
|
||||||
|
setTab('settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
focusSignatureField();
|
||||||
|
};
|
||||||
|
|
||||||
|
const goEditProfile = () => {
|
||||||
|
if (tab !== 'settings') {
|
||||||
|
setTab('settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab !== 'settings' || !pendingFocusSig.current) return;
|
||||||
|
pendingFocusSig.current = false;
|
||||||
|
const t = window.setTimeout(focusSignatureField, 40);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [tab, focusSignatureField]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading && !user) {
|
if (!authLoading && !user) {
|
||||||
nav(loginPath('/profile'));
|
nav(loginPath('/profile'));
|
||||||
@@ -334,8 +369,9 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
const tabs: { key: ProfileTab; label: string; count?: number }[] = [
|
const tabs: { key: ProfileTab; label: string; count?: number }[] = [
|
||||||
{ key: 'posts', label: '我的帖子', count: stats?.post_count },
|
{ key: 'posts', label: '我的帖子', count: stats?.post_count },
|
||||||
{ key: 'settings', label: '资料设置' },
|
{ key: 'points', label: '积分' },
|
||||||
{ key: 'security', label: '安全设置' },
|
{ key: 'settings', label: '资料' },
|
||||||
|
{ key: 'security', label: '安全' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -415,29 +451,38 @@ export default function ProfilePage() {
|
|||||||
>
|
>
|
||||||
公开主页
|
公开主页
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="profile-id-copy"
|
||||||
|
onClick={goEditProfile}
|
||||||
|
>
|
||||||
|
<PenLine size={13} aria-hidden />
|
||||||
|
编辑资料
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{user.signature?.trim() ? (
|
{user.signature?.trim() ? (
|
||||||
<p className="profile-signature">{user.signature}</p>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="profile-signature profile-signature--editable"
|
||||||
|
onClick={goEditSignature}
|
||||||
|
title="编辑个性签名"
|
||||||
|
>
|
||||||
|
<span className="profile-signature-text">{user.signature}</span>
|
||||||
|
<span className="profile-signature-edit">
|
||||||
|
<PenLine size={13} aria-hidden />
|
||||||
|
编辑
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<p className="profile-signature profile-signature--empty">尚未设置签名</p>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="profile-signature profile-signature--empty profile-signature--editable"
|
||||||
|
onClick={goEditSignature}
|
||||||
|
>
|
||||||
|
<PenLine size={14} aria-hidden />
|
||||||
|
点击设置个性签名
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
<dl className="profile-meta-list">
|
|
||||||
<div>
|
|
||||||
<dt>用户名</dt>
|
|
||||||
<dd>{user.username}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>邮箱</dt>
|
|
||||||
<dd>{user.email || '未设置'}</dd>
|
|
||||||
</div>
|
|
||||||
{joinedAt && (
|
|
||||||
<div>
|
|
||||||
<dt>注册时间</dt>
|
|
||||||
<dd>{joinedAt}</dd>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</dl>
|
|
||||||
<p className="profile-avatar-tip">点击头像选择图片,或拖拽到此处更换</p>
|
|
||||||
</div>
|
</div>
|
||||||
{pendingAvatar && (
|
{pendingAvatar && (
|
||||||
<div className="profile-avatar-actions">
|
<div className="profile-avatar-actions">
|
||||||
@@ -491,22 +536,19 @@ export default function ProfilePage() {
|
|||||||
onConfirm={onCropConfirm}
|
onConfirm={onCropConfirm}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PointsWalletPanel />
|
|
||||||
|
|
||||||
{user.role === 'admin' && (
|
{user.role === 'admin' && (
|
||||||
<div className="section-card admin-entry-card">
|
<div className="admin-entry-bar">
|
||||||
<div className="section-card-title">站长入口</div>
|
<span className="admin-entry-bar-label">
|
||||||
<p className="admin-entry-desc">
|
<Settings size={14} aria-hidden />
|
||||||
管理板块、用户、帖子及系统设置
|
站长
|
||||||
</p>
|
</span>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="admin-entry-bar-actions">
|
||||||
<Button variant="outline" onClick={() => nav('/admin/boards')}>
|
<Button size="sm" variant="outline" onClick={() => nav('/admin/boards')}>
|
||||||
<Settings />
|
|
||||||
管理板块
|
管理板块
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => nav('/admin/dashboard')}>
|
<Button size="sm" onClick={() => nav('/admin/dashboard')}>
|
||||||
<LayoutDashboard />
|
<LayoutDashboard size={14} />
|
||||||
进入系统后台
|
系统后台
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -522,6 +564,7 @@ export default function ProfilePage() {
|
|||||||
className={`profile-tab${tab === t.key ? ' active' : ''}`}
|
className={`profile-tab${tab === t.key ? ' active' : ''}`}
|
||||||
onClick={() => setTab(t.key)}
|
onClick={() => setTab(t.key)}
|
||||||
>
|
>
|
||||||
|
{t.key === 'points' && <Coins size={14} aria-hidden className="profile-tab-icon" />}
|
||||||
{t.label}
|
{t.label}
|
||||||
{typeof t.count === 'number' && (
|
{typeof t.count === 'number' && (
|
||||||
<span className="profile-tab-count">{t.count}</span>
|
<span className="profile-tab-count">{t.count}</span>
|
||||||
@@ -565,29 +608,20 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === 'points' && (
|
||||||
|
<div className="profile-panel profile-panel--points">
|
||||||
|
<PointsWalletPanel />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{tab === 'settings' && (
|
{tab === 'settings' && (
|
||||||
<div className="section-card">
|
<div className="section-card">
|
||||||
<div className="section-card-title">基本资料</div>
|
<div className="section-card-title">展示资料</div>
|
||||||
|
<p className="profile-settings-lead">
|
||||||
|
昵称与个性签名会显示在公开主页和帖子旁
|
||||||
|
</p>
|
||||||
<Form {...nickForm}>
|
<Form {...nickForm}>
|
||||||
<form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="profile-form">
|
<form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="profile-form">
|
||||||
<FormItem>
|
|
||||||
<FormLabel>用户 ID</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input value={String(user.id)} disabled />
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>用户名</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input value={user.username} disabled />
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>邮箱</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input value={user.email || '未设置'} disabled />
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
<FormField
|
<FormField
|
||||||
control={nickForm.control}
|
control={nickForm.control}
|
||||||
name="nickname"
|
name="nickname"
|
||||||
@@ -603,7 +637,7 @@ export default function ProfilePage() {
|
|||||||
/>
|
/>
|
||||||
<div className="profile-form-footer">
|
<div className="profile-form-footer">
|
||||||
<span className="profile-form-hint">
|
<span className="profile-form-hint">
|
||||||
用户名与 ID 不可修改;头像支持 JPG / PNG / GIF / WebP,服务端保留原图并生成 WebP,裁剪后不超过 {limits.avatar_max_mb}MB
|
头像可在上方点击或拖拽更换;支持 JPG / PNG / GIF / WebP,裁剪后不超过 {limits.avatar_max_mb}MB
|
||||||
</span>
|
</span>
|
||||||
<Button type="submit" loading={nickLoading}>保存昵称</Button>
|
<Button type="submit" loading={nickLoading}>保存昵称</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -612,6 +646,7 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
<div className="profile-form-divider" />
|
<div className="profile-form-divider" />
|
||||||
|
|
||||||
|
<div ref={sigSectionRef} id="profile-signature">
|
||||||
<Form {...sigForm}>
|
<Form {...sigForm}>
|
||||||
<form onSubmit={sigForm.handleSubmit(onUpdateSig)} className="profile-form">
|
<form onSubmit={sigForm.handleSubmit(onUpdateSig)} className="profile-form">
|
||||||
<FormField
|
<FormField
|
||||||
@@ -619,12 +654,12 @@ export default function ProfilePage() {
|
|||||||
name="signature"
|
name="signature"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>个人签名</FormLabel>
|
<FormLabel>个性签名</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
rows={3}
|
rows={3}
|
||||||
maxLength={sigMax}
|
maxLength={sigMax}
|
||||||
placeholder="写一句介绍自己的话,会显示在公开主页"
|
placeholder="写一句介绍自己的话,会显示在公开主页与个人中心顶部"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -641,6 +676,32 @@ export default function ProfilePage() {
|
|||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="profile-form-divider" />
|
||||||
|
|
||||||
|
<div className="section-card-title section-card-title--sub">账号信息</div>
|
||||||
|
<div className="profile-form profile-form--readonly">
|
||||||
|
<div className="profile-readonly-field">
|
||||||
|
<Label htmlFor="profile-readonly-id">用户 ID</Label>
|
||||||
|
<Input id="profile-readonly-id" value={String(user.id)} disabled />
|
||||||
|
</div>
|
||||||
|
<div className="profile-readonly-field">
|
||||||
|
<Label htmlFor="profile-readonly-username">用户名</Label>
|
||||||
|
<Input id="profile-readonly-username" value={user.username} disabled />
|
||||||
|
</div>
|
||||||
|
<div className="profile-readonly-field">
|
||||||
|
<Label htmlFor="profile-readonly-email">邮箱</Label>
|
||||||
|
<Input id="profile-readonly-email" value={user.email || '未设置'} disabled />
|
||||||
|
</div>
|
||||||
|
{joinedAt && (
|
||||||
|
<div className="profile-readonly-field">
|
||||||
|
<Label htmlFor="profile-readonly-joined">注册时间</Label>
|
||||||
|
<Input id="profile-readonly-joined" value={joinedAt} disabled />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="profile-form-hint">用户名与 ID 注册后不可修改</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'security' && (
|
{tab === 'security' && (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Trash2, RotateCcw } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
@@ -16,7 +17,8 @@ import type { Comment } from '../../api/types';
|
|||||||
import CommentRevisionDialog from '../../components/CommentRevisionDialog';
|
import CommentRevisionDialog from '../../components/CommentRevisionDialog';
|
||||||
import { isTimeDiffSignificant } from '../../utils/content';
|
import { isTimeDiffSignificant } from '../../utils/content';
|
||||||
|
|
||||||
type Tab = 'pending' | 'all';
|
type Tab = 'pending' | 'all' | 'trash';
|
||||||
|
type TrashComment = Comment & { deleted_at: string };
|
||||||
|
|
||||||
function statusLabel(status?: string) {
|
function statusLabel(status?: string) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -27,18 +29,28 @@ function statusLabel(status?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 AdminCommentsPage() {
|
export default function AdminCommentsPage() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const { ready } = useAdminGuard();
|
const { ready } = useAdminGuard();
|
||||||
const [tab, setTab] = useState<Tab>('pending');
|
const [tab, setTab] = useState<Tab>('pending');
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
|
const [trash, setTrash] = useState<TrashComment[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [pendingCount, setPendingCount] = useState(0);
|
const [pendingCount, setPendingCount] = useState(0);
|
||||||
const [revComment, setRevComment] = useState<Comment | null>(null);
|
const [revComment, setRevComment] = useState<Comment | null>(null);
|
||||||
|
|
||||||
const load = (p = page, st: Tab = tab) => {
|
const loadList = (p = page, st: Tab = tab) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api.adminComments({ page: p, status: st === 'pending' ? 'pending' : 'all' })
|
api.adminComments({ page: p, status: st === 'pending' ? 'pending' : 'all' })
|
||||||
.then(d => {
|
.then(d => {
|
||||||
@@ -51,6 +63,28 @@ export default function AdminCommentsPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadTrash = (p = page) => {
|
||||||
|
setLoading(true);
|
||||||
|
api.adminTrashComments({ page: p })
|
||||||
|
.then(d => {
|
||||||
|
setTrash(d.comments ?? []);
|
||||||
|
setPage(d.page);
|
||||||
|
setTotalPages(d.total_pages);
|
||||||
|
})
|
||||||
|
.catch(e => notify.error(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = (p = page, st: Tab = tab) => {
|
||||||
|
if (st === 'trash') loadTrash(p);
|
||||||
|
else loadList(p, st);
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchTab = (next: Tab) => {
|
||||||
|
setTab(next);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ready) load(1, tab);
|
if (ready) load(1, tab);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -81,42 +115,141 @@ export default function AdminCommentsPage() {
|
|||||||
const remove = async (id: number) => {
|
const remove = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await api.adminDeleteComment(id);
|
await api.adminDeleteComment(id);
|
||||||
notify.success('评论已删除');
|
notify.success('评论已移入回收站');
|
||||||
load();
|
load();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
notify.error(e instanceof Error ? e.message : '删除失败');
|
notify.error(e instanceof Error ? e.message : '删除失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const restore = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await api.adminRestoreComment(id);
|
||||||
|
notify.success('评论已恢复');
|
||||||
|
loadTrash(page);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
notify.error(e instanceof Error ? e.message : '恢复失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const purge = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await api.adminPurgeComment(id);
|
||||||
|
notify.success('评论已永久删除');
|
||||||
|
loadTrash(page);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
notify.error(e instanceof Error ? e.message : '永久删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!ready) return null;
|
if (!ready) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-page">
|
<div className="admin-page">
|
||||||
<div className="admin-page-head">
|
<div className="admin-page-head">
|
||||||
<h1>评论管理</h1>
|
<h1>评论管理</h1>
|
||||||
<p>审核普通用户评论;通过后公开,拒绝后仅作者可见并私信通知。</p>
|
<p>
|
||||||
|
{tab === 'trash'
|
||||||
|
? '回收站中的评论可恢复或永久删除;永久删除后不可撤销'
|
||||||
|
: '审核普通用户评论;通过后公开,拒绝后仅作者可见并私信通知。删除将移入回收站。'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-tabs" role="tablist">
|
<div className="admin-tabs" role="tablist">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn('admin-tab', tab === 'pending' && 'active')}
|
className={cn('admin-tab', tab === 'pending' && 'active')}
|
||||||
onClick={() => setTab('pending')}
|
onClick={() => switchTab('pending')}
|
||||||
>
|
>
|
||||||
待审核{pendingCount > 0 ? ` (${pendingCount})` : ''}
|
待审核{pendingCount > 0 ? ` (${pendingCount})` : ''}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn('admin-tab', tab === 'all' && 'active')}
|
className={cn('admin-tab', tab === 'all' && 'active')}
|
||||||
onClick={() => setTab('all')}
|
onClick={() => switchTab('all')}
|
||||||
>
|
>
|
||||||
全部评论
|
全部评论
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-selected={tab === 'trash'}
|
||||||
|
className={cn('admin-tab', tab === 'trash' && 'active')}
|
||||||
|
onClick={() => switchTab('trash')}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} aria-hidden />
|
||||||
|
回收站
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-card">
|
<div className="admin-card">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
|
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
|
||||||
|
) : tab === 'trash' ? (
|
||||||
|
<>
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>楼层</th>
|
||||||
|
<th>帖子</th>
|
||||||
|
<th>作者</th>
|
||||||
|
<th>内容</th>
|
||||||
|
<th>删除时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{trash.map(c => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td>{c.id}</td>
|
||||||
|
<td>#{c.floor}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" className="admin-text-link" onClick={() => nav(`/post/${c.post_id}`)}>
|
||||||
|
{c.post?.title ?? `#${c.post_id}`}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{c.user_id && c.user ? c.user.nickname : (c.guest_nick || '游客')}
|
||||||
|
</td>
|
||||||
|
<td className="max-w-[200px] truncate">{c.content}</td>
|
||||||
|
<td className="text-sm whitespace-nowrap">{formatAdminTime(c.deleted_at)}</td>
|
||||||
|
<td>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => restore(c.id)}>
|
||||||
|
<RotateCcw size={14} /> 恢复
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button size="sm" variant="ghost" className="text-destructive">永久删除</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>永久删除该评论?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
将彻底清除该评论及其已删回复、修订与点赞,此操作不可恢复。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => purge(c.id)}>永久删除</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{trash.length === 0 && <div className="admin-empty">回收站为空</div>}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="admin-pagination">
|
||||||
|
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => loadTrash(page - 1)}>上一页</Button>
|
||||||
|
<span>{page} / {totalPages}</span>
|
||||||
|
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => loadTrash(page + 1)}>下一页</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
@@ -157,7 +290,7 @@ export default function AdminCommentsPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td>{c.is_private ? <Badge variant="secondary">是</Badge> : '—'}</td>
|
<td>{c.is_private ? <Badge variant="secondary">是</Badge> : '—'}</td>
|
||||||
<td>{new Date(c.created_at).toLocaleString('zh-CN')}</td>
|
<td>{formatAdminTime(c.created_at)}</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="flex gap-1 flex-wrap">
|
<div className="flex gap-1 flex-wrap">
|
||||||
{(c.status === 'pending' || c.status === 'rejected') && (
|
{(c.status === 'pending' || c.status === 'rejected') && (
|
||||||
@@ -175,12 +308,14 @@ export default function AdminCommentsPage() {
|
|||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>确定删除该评论?</AlertDialogTitle>
|
<AlertDialogTitle>移入回收站?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>删除后不可恢复。</AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
|
将同时移入其下所有回复,可随时恢复;永久删除请到回收站操作。
|
||||||
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={() => remove(c.id)}>删除</AlertDialogAction>
|
<AlertDialogAction onClick={() => remove(c.id)}>移入回收站</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|||||||
@@ -1512,14 +1512,25 @@ img.site-brand-logo-img {
|
|||||||
/* 手机端自定义下拉刷新指示器(原生 PTR 依赖 document 滚动) */
|
/* 手机端自定义下拉刷新指示器(原生 PTR 依赖 document 滚动) */
|
||||||
.ptr-indicator {
|
.ptr-indicator {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: env(safe-area-inset-top, 0px);
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 200;
|
z-index: 90;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: transform 0.12s ease-out;
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 有顶栏时从顶栏下方露出,避免叠在导航上造成「气泡错位」 */
|
||||||
|
body:has(.app-header) .ptr-indicator,
|
||||||
|
body:has(.admin-topbar) .ptr-indicator {
|
||||||
|
top: calc(var(--j13-header-h) + env(safe-area-inset-top, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 仅松手回弹 / 进入刷新态时过渡,拖动跟手不加 transition */
|
||||||
|
.ptr-indicator--settling {
|
||||||
|
transition: transform 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ptr-indicator__chip {
|
.ptr-indicator__chip {
|
||||||
@@ -2297,7 +2308,7 @@ img.site-brand-logo-img {
|
|||||||
gap: 3px;
|
gap: 3px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
padding: 0 7px 0 5px;
|
padding: 0 7px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -2310,17 +2321,12 @@ img.site-brand-logo-img {
|
|||||||
border: 1px solid rgba(220, 38, 38, 0.22);
|
border: 1px solid rgba(220, 38, 38, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-pin-badge--icon {
|
|
||||||
width: 20px;
|
|
||||||
padding: 0;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-pin-badge--detail {
|
.post-pin-badge--detail {
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
vertical-align: -3px;
|
height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .post-pin-badge {
|
.dark .post-pin-badge {
|
||||||
@@ -2417,6 +2423,7 @@ img.site-brand-logo-img {
|
|||||||
.waline-comment-status--rejected { color: #e11d48; }
|
.waline-comment-status--rejected { color: #e11d48; }
|
||||||
|
|
||||||
.post-feature-badge {
|
.post-feature-badge {
|
||||||
|
padding: 0 7px 0 5px;
|
||||||
color: #b45309;
|
color: #b45309;
|
||||||
background: rgba(245, 158, 11, 0.12);
|
background: rgba(245, 158, 11, 0.12);
|
||||||
border: 1px solid rgba(245, 158, 11, 0.22);
|
border: 1px solid rgba(245, 158, 11, 0.22);
|
||||||
@@ -2428,7 +2435,6 @@ img.site-brand-logo-img {
|
|||||||
border-color: rgba(251, 191, 36, 0.22);
|
border-color: rgba(251, 191, 36, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-pin-badge .post-pinned-icon,
|
|
||||||
.post-feature-badge .post-featured-icon {
|
.post-feature-badge .post-featured-icon {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
vertical-align: 0;
|
vertical-align: 0;
|
||||||
@@ -2531,14 +2537,6 @@ a.post-title:visited {
|
|||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-pinned-icon {
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: -0.15em;
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--j13-green);
|
|
||||||
stroke-width: 1.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-featured-icon {
|
.post-featured-icon {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
vertical-align: -0.15em;
|
vertical-align: -0.15em;
|
||||||
@@ -5789,9 +5787,9 @@ a.waline-comment-author:hover {
|
|||||||
"avatar info"
|
"avatar info"
|
||||||
"stats stats";
|
"stats stats";
|
||||||
align-items: start;
|
align-items: start;
|
||||||
gap: 20px 24px;
|
gap: 16px 20px;
|
||||||
padding: 24px;
|
padding: 20px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 12px;
|
||||||
background: var(--j13-bg-block);
|
background: var(--j13-bg-block);
|
||||||
border: 1px solid var(--j13-border-light);
|
border: 1px solid var(--j13-border-light);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -5927,7 +5925,7 @@ a.waline-comment-author:hover {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 10px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-id-chip {
|
.profile-id-chip {
|
||||||
@@ -5991,7 +5989,7 @@ a.waline-comment-author:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.profile-signature {
|
.profile-signature {
|
||||||
margin: 12px 0 0;
|
margin: 10px 0 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
color: var(--color-text-2);
|
color: var(--color-text-2);
|
||||||
@@ -6004,9 +6002,94 @@ a.waline-comment-author:hover {
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button.profile-signature--editable {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
margin-left: -8px;
|
||||||
|
border: 1px dashed transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.profile-signature--editable:hover,
|
||||||
|
button.profile-signature--editable:focus-visible {
|
||||||
|
background: var(--j13-green-bg);
|
||||||
|
border-color: color-mix(in srgb, var(--j13-green) 35%, transparent);
|
||||||
|
color: var(--j13-green);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.profile-signature--editable.profile-signature--empty {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.profile-signature--editable.profile-signature--empty:hover,
|
||||||
|
button.profile-signature--editable.profile-signature--empty:focus-visible {
|
||||||
|
color: var(--j13-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-signature-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-signature-edit {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
margin-top: 1px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.profile-signature--editable:hover .profile-signature-edit,
|
||||||
|
button.profile-signature--editable:focus-visible .profile-signature-edit {
|
||||||
|
color: var(--j13-green);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-settings-lead {
|
||||||
|
margin: -4px 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card-title--sub {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-readonly-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-form--readonly {
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-form-divider {
|
.profile-form-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
margin: 24px 0;
|
margin: 20px 0;
|
||||||
background: var(--j13-border-light);
|
background: var(--j13-border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6167,12 +6250,18 @@ button.profile-stat:hover strong {
|
|||||||
.profile-tabs {
|
.profile-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
margin: 4px 0 16px;
|
margin: 0 0 16px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
border: 1px solid var(--j13-border-light);
|
border: 1px solid var(--j13-border-light);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: var(--j13-bg-block);
|
background: var(--j13-bg-block);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-tabs::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-tab {
|
.profile-tab {
|
||||||
@@ -6194,6 +6283,11 @@ button.profile-stat:hover strong {
|
|||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-tab-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-tab:hover {
|
.profile-tab:hover {
|
||||||
color: var(--color-text-1);
|
color: var(--color-text-1);
|
||||||
background: var(--j13-bg-surface);
|
background: var(--j13-bg-surface);
|
||||||
@@ -6219,6 +6313,10 @@ button.profile-stat:hover strong {
|
|||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-panel--points .points-wallet {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-avatar-actions {
|
.profile-avatar-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -6282,6 +6380,12 @@ button.profile-stat:hover strong {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button.profile-signature--editable {
|
||||||
|
margin-left: 0;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-meta-list {
|
.profile-meta-list {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -6292,6 +6396,24 @@ button.profile-stat:hover strong {
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-tab {
|
||||||
|
padding: 9px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-entry-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-entry-bar-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-entry-bar-actions > * {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-form-footer {
|
.profile-form-footer {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@@ -6350,6 +6472,35 @@ button.profile-stat:hover strong {
|
|||||||
background: var(--j13-bg-block-accent);
|
background: var(--j13-bg-block-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 个人中心:站长入口紧凑条(避免挡在资料 Tab 前) */
|
||||||
|
.admin-entry-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--j13-green) 22%, var(--j13-border-light));
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--j13-bg-block-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-entry-bar-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--j13-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-entry-bar-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 通用懒加载占位:非 Feed 页勿用首页鱼骨骨架 */
|
/* 通用懒加载占位:非 Feed 页勿用首页鱼骨骨架 */
|
||||||
.page-loader {
|
.page-loader {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -22,6 +22,22 @@ export function isGuestComment(c: Comment): boolean {
|
|||||||
return !c.user_id || c.user_id === 0;
|
return !c.user_id || c.user_id === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 收集评论及其 reply_to 后代的 ID(含自身) */
|
||||||
|
export function collectCommentSubtreeIds(comments: Comment[], rootId: number): Set<number> {
|
||||||
|
const ids = new Set<number>([rootId]);
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const c of comments) {
|
||||||
|
if (!ids.has(c.id) && c.reply_to != null && ids.has(c.reply_to)) {
|
||||||
|
ids.add(c.id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
/** 构建嵌套评论树(优先 thread_parent_id,回退 reply_to) */
|
/** 构建嵌套评论树(优先 thread_parent_id,回退 reply_to) */
|
||||||
export function buildCommentTree(comments: Comment[]): CommentNode[] {
|
export function buildCommentTree(comments: Comment[]): CommentNode[] {
|
||||||
const map = new Map<number, CommentNode>();
|
const map = new Map<number, CommentNode>();
|
||||||
|
|||||||
@@ -40,9 +40,47 @@ function readDisplayOptions(pre: Element) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在换行处闭合并重开跨行 <span>,使每行 HTML 片段自包含。
|
||||||
|
* hljs token 常跨多行,直接按 \\n 切开会破坏标签导致行号布局叠字。
|
||||||
|
*/
|
||||||
|
function balanceHighlightLines(highlightedHtml: string): string[] {
|
||||||
|
const openTags: string[] = [];
|
||||||
|
let balanced = '';
|
||||||
|
const tokenRe = /(<span\b[^>]*>)|(<\/span>)|(\n)/g;
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = tokenRe.exec(highlightedHtml)) !== null) {
|
||||||
|
balanced += highlightedHtml.slice(lastIndex, match.index);
|
||||||
|
lastIndex = tokenRe.lastIndex;
|
||||||
|
|
||||||
|
if (match[3] !== undefined) {
|
||||||
|
// 换行:先闭合当前栈,再于下一行重开
|
||||||
|
for (let i = openTags.length - 1; i >= 0; i--) balanced += '</span>';
|
||||||
|
balanced += '\n';
|
||||||
|
for (const tag of openTags) balanced += tag;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match[2] !== undefined) {
|
||||||
|
openTags.pop();
|
||||||
|
balanced += match[2];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开标签
|
||||||
|
openTags.push(match[1]);
|
||||||
|
balanced += match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
balanced += highlightedHtml.slice(lastIndex);
|
||||||
|
return balanced.split('\n');
|
||||||
|
}
|
||||||
|
|
||||||
/** 为高亮后的 HTML 按行包一层,便于行号与折叠计数 */
|
/** 为高亮后的 HTML 按行包一层,便于行号与折叠计数 */
|
||||||
function wrapCodeLines(highlightedHtml: string, withLineNumbers: boolean): string {
|
function wrapCodeLines(highlightedHtml: string, withLineNumbers: boolean): string {
|
||||||
const lines = highlightedHtml.split('\n');
|
const lines = balanceHighlightLines(highlightedHtml);
|
||||||
return lines
|
return lines
|
||||||
.map((line, i) => {
|
.map((line, i) => {
|
||||||
const num = i + 1;
|
const num = i + 1;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { marked } from 'marked';
|
|||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
|
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
|
||||||
import { parseFenceInfo, formatFenceInfo } from './codeBlockOptions';
|
import { parseFenceInfo, formatFenceInfo } from './codeBlockOptions';
|
||||||
|
import { mapOutsideFences, wrapFencedCode } from './markdownFences';
|
||||||
|
|
||||||
const GATED_BLOCK_RE = /<(members-only|reply-only)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi;
|
const GATED_BLOCK_RE = /<(members-only|reply-only)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi;
|
||||||
|
|
||||||
@@ -80,7 +81,7 @@ function addTurndownContentRules(service: TurndownService): void {
|
|||||||
const collapsed = pre.getAttribute('data-collapsed') === 'true'
|
const collapsed = pre.getAttribute('data-collapsed') === 'true'
|
||||||
|| wrap?.getAttribute('data-collapsed') === 'true';
|
|| wrap?.getAttribute('data-collapsed') === 'true';
|
||||||
const info = formatFenceInfo({ language, lineNumbers, collapsed });
|
const info = formatFenceInfo({ language, lineNumbers, collapsed });
|
||||||
return `\n\n\`\`\`${info}\n${text}\n\`\`\`\n\n`;
|
return wrapFencedCode(info, text);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -284,10 +285,9 @@ function sanitizeContentHtml(html: string): string {
|
|||||||
|
|
||||||
/** 将行首空格转为不换行空格,避免 HTML 折叠缩进;跳过围栏代码块 */
|
/** 将行首空格转为不换行空格,避免 HTML 折叠缩进;跳过围栏代码块 */
|
||||||
function preserveLeadingIndent(markdown: string): string {
|
function preserveLeadingIndent(markdown: string): string {
|
||||||
return markdown.split(/(```[\s\S]*?```)/g).map((part, index) => {
|
return mapOutsideFences(markdown, (outside) =>
|
||||||
if (index % 2 === 1) return part;
|
outside.replace(/^( +)(?=\S)/gm, (_match, spaces: string) => '\u00A0'.repeat(spaces.length)),
|
||||||
return part.replace(/^( +)(?=\S)/gm, (_match, spaces: string) => '\u00A0'.repeat(spaces.length));
|
);
|
||||||
}).join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将普通 Markdown 片段转为 HTML */
|
/** 将普通 Markdown 片段转为 HTML */
|
||||||
|
|||||||
73
frontend/src/utils/markdownFences.ts
Normal file
73
frontend/src/utils/markdownFences.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/** CommonMark 围栏:开围栏行(最多 3 空格缩进 + 至少 3 个反引号) */
|
||||||
|
const OPEN_FENCE_RE = /^ {0,3}(`{3,})([^`\n]*)$/;
|
||||||
|
/** 闭围栏行:仅反引号与可选尾随空白 */
|
||||||
|
const CLOSE_FENCE_RE = /^ {0,3}(`{3,})[ \t]*$/;
|
||||||
|
|
||||||
|
/** 正文中最长连续反引号数;外层围栏需至少 longest+1(且 ≥ 3) */
|
||||||
|
export function fenceLengthForContent(text: string): number {
|
||||||
|
let longest = 0;
|
||||||
|
let run = 0;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (text[i] === '`') {
|
||||||
|
run += 1;
|
||||||
|
if (run > longest) longest = run;
|
||||||
|
} else {
|
||||||
|
run = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Math.max(3, longest + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用足够长的围栏包裹代码正文(info 为语言/选项串,可为空) */
|
||||||
|
export function wrapFencedCode(info: string, text: string): string {
|
||||||
|
const len = fenceLengthForContent(text);
|
||||||
|
const fence = '`'.repeat(len);
|
||||||
|
const open = info ? `${fence}${info}` : fence;
|
||||||
|
return `\n\n${open}\n${text}\n${fence}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按行识别围栏块;仅对围栏外文本调用 fn。
|
||||||
|
* 闭合条件:行首闭围栏长度 ≥ 开围栏(CommonMark)。
|
||||||
|
*/
|
||||||
|
export function mapOutsideFences(markdown: string, fn: (outside: string) => string): string {
|
||||||
|
const lines = markdown.split('\n');
|
||||||
|
const out: string[] = [];
|
||||||
|
let i = 0;
|
||||||
|
let outsideBuf: string[] = [];
|
||||||
|
|
||||||
|
const flushOutside = () => {
|
||||||
|
if (outsideBuf.length === 0) return;
|
||||||
|
out.push(fn(outsideBuf.join('\n')));
|
||||||
|
outsideBuf = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
const openMatch = lines[i].match(OPEN_FENCE_RE);
|
||||||
|
if (!openMatch) {
|
||||||
|
outsideBuf.push(lines[i]);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
flushOutside();
|
||||||
|
const openLen = openMatch[1].length;
|
||||||
|
const fenceLines = [lines[i]];
|
||||||
|
i += 1;
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
fenceLines.push(lines[i]);
|
||||||
|
const closeMatch = lines[i].match(CLOSE_FENCE_RE);
|
||||||
|
if (closeMatch && closeMatch[1].length >= openLen) {
|
||||||
|
i += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push(fenceLines.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
flushOutside();
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
@@ -365,14 +365,53 @@ func (h *Handlers) APIAdminRejectComment(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "已拒绝该评论并通知作者", "status": model.ContentStatusRejected})
|
c.JSON(http.StatusOK, gin.H{"message": "已拒绝该评论并通知作者", "status": model.ContentStatusRejected})
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIAdminDeleteComment 管理员删除评论
|
// APIAdminDeleteComment 管理员软删除评论(进入回收站)
|
||||||
func (h *Handlers) APIAdminDeleteComment(c *gin.Context) {
|
func (h *Handlers) APIAdminDeleteComment(c *gin.Context) {
|
||||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err := h.Comment.AdminDelete(uint(id)); err != nil {
|
if err := h.Comment.AdminDelete(uint(id)); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "评论已删除"})
|
c.JSON(http.StatusOK, gin.H{"message": "评论已移入回收站"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminTrashComments 评论回收站列表
|
||||||
|
func (h *Handlers) APIAdminTrashComments(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||||
|
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||||
|
comments, total, err := h.Comment.ListTrash(page, size, keyword)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if comments == nil {
|
||||||
|
comments = []service.TrashCommentItem{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"comments": comments, "total": total, "page": page,
|
||||||
|
"total_pages": calcTotalPages(total, size),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminRestoreComment 从回收站恢复评论
|
||||||
|
func (h *Handlers) APIAdminRestoreComment(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err := h.Comment.Restore(uint(id)); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "评论已恢复"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminPurgeComment 永久删除回收站评论
|
||||||
|
func (h *Handlers) APIAdminPurgeComment(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err := h.Comment.Purge(uint(id)); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "评论已永久删除"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIAdminCommentRevisions 管理员查看评论编辑历史
|
// APIAdminCommentRevisions 管理员查看评论编辑历史
|
||||||
|
|||||||
@@ -484,7 +484,7 @@ func (h *Handlers) APIDeleteComment(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "评论已删除"})
|
c.JSON(http.StatusOK, gin.H{"message": "评论已移入回收站"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) APIUpdateComment(c *gin.Context) {
|
func (h *Handlers) APIUpdateComment(c *gin.Context) {
|
||||||
|
|||||||
@@ -204,9 +204,12 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
adminAPI.GET("/reports", h.APIAdminReports)
|
adminAPI.GET("/reports", h.APIAdminReports)
|
||||||
adminAPI.POST("/reports/:id/handle", h.APIAdminHandleReport)
|
adminAPI.POST("/reports/:id/handle", h.APIAdminHandleReport)
|
||||||
adminAPI.GET("/comments", h.APIAdminComments)
|
adminAPI.GET("/comments", h.APIAdminComments)
|
||||||
|
adminAPI.GET("/comments/trash", h.APIAdminTrashComments)
|
||||||
adminAPI.GET("/comments/:id/revisions", h.APIAdminCommentRevisions)
|
adminAPI.GET("/comments/:id/revisions", h.APIAdminCommentRevisions)
|
||||||
adminAPI.POST("/comments/:id/approve", h.APIAdminApproveComment)
|
adminAPI.POST("/comments/:id/approve", h.APIAdminApproveComment)
|
||||||
adminAPI.POST("/comments/:id/reject", h.APIAdminRejectComment)
|
adminAPI.POST("/comments/:id/reject", h.APIAdminRejectComment)
|
||||||
|
adminAPI.POST("/comments/:id/restore", h.APIAdminRestoreComment)
|
||||||
|
adminAPI.DELETE("/comments/:id/purge", h.APIAdminPurgeComment)
|
||||||
adminAPI.DELETE("/comments/:id", h.APIAdminDeleteComment)
|
adminAPI.DELETE("/comments/:id", h.APIAdminDeleteComment)
|
||||||
adminAPI.GET("/users", h.APIAdminUsers)
|
adminAPI.GET("/users", h.APIAdminUsers)
|
||||||
adminAPI.POST("/users/:id/ban", h.APIAdminBanUser)
|
adminAPI.POST("/users/:id/ban", h.APIAdminBanUser)
|
||||||
|
|||||||
@@ -395,12 +395,137 @@ func (s *CommentService) Update(userID, commentID uint, isAdmin, skipModeration
|
|||||||
return content, enteredPending, nil
|
return content, enteredPending, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// collectReplySubtreeIDs 沿 reply_to BFS 收集子树 ID(含 rootID)
|
||||||
|
// softDeletedOnly 为 true 时仅收集已软删节点(用于回收站恢复/永久删除)
|
||||||
|
func collectReplySubtreeIDs(db *gorm.DB, rootID uint, softDeletedOnly bool) ([]uint, error) {
|
||||||
|
q := db
|
||||||
|
if softDeletedOnly {
|
||||||
|
q = db.Unscoped()
|
||||||
|
}
|
||||||
|
ids := []uint{rootID}
|
||||||
|
seen := map[uint]struct{}{rootID: {}}
|
||||||
|
frontier := []uint{rootID}
|
||||||
|
for len(frontier) > 0 {
|
||||||
|
childQ := q.Model(&model.Comment{}).Select("id").Where("reply_to IN ?", frontier)
|
||||||
|
if softDeletedOnly {
|
||||||
|
childQ = childQ.Where("deleted_at IS NOT NULL")
|
||||||
|
}
|
||||||
|
var children []model.Comment
|
||||||
|
if err := childQ.Find(&children).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
frontier = frontier[:0]
|
||||||
|
for _, c := range children {
|
||||||
|
if _, ok := seen[c.ID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[c.ID] = struct{}{}
|
||||||
|
ids = append(ids, c.ID)
|
||||||
|
frontier = append(frontier, c.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDelete 软删除评论及其回复树(进入回收站);修订与点赞保留以便恢复
|
||||||
func (s *CommentService) AdminDelete(commentID uint) error {
|
func (s *CommentService) AdminDelete(commentID uint) error {
|
||||||
return model.DB.Transaction(func(tx *gorm.DB) error {
|
var root model.Comment
|
||||||
if err := tx.Where("comment_id = ?", commentID).Delete(&model.CommentRevision{}).Error; err != nil {
|
if err := model.DB.First(&root, commentID).Error; err != nil {
|
||||||
|
return ErrCommentNotFound
|
||||||
|
}
|
||||||
|
ids, err := collectReplySubtreeIDs(model.DB, commentID, false)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Delete(&model.Comment{}, commentID).Error
|
return model.DB.Where("id IN ?", ids).Delete(&model.Comment{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrashCommentItem 评论回收站列表项
|
||||||
|
type TrashCommentItem struct {
|
||||||
|
model.Comment
|
||||||
|
DeletedAt time.Time `json:"deleted_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTrash 列出已软删评论(不含随帖子一并删除的评论,那些在帖子回收站处理)
|
||||||
|
func (s *CommentService) ListTrash(page, size int, keyword string) ([]TrashCommentItem, int64, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
size = s.settings.NormalizePageSize(size)
|
||||||
|
db := model.DB.Unscoped().Model(&model.Comment{}).
|
||||||
|
Where("comments.deleted_at IS NOT NULL").
|
||||||
|
Joins("JOIN posts ON posts.id = comments.post_id AND posts.deleted_at IS NULL").
|
||||||
|
Preload("User").Preload("Post")
|
||||||
|
if keyword != "" {
|
||||||
|
kw, err := s.settings.NormalizeSearchKeyword(keyword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
like := "%" + kw + "%"
|
||||||
|
db = db.Where("comments.content LIKE ? OR posts.title LIKE ?", like, like)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var comments []model.Comment
|
||||||
|
if err := db.Order("comments.deleted_at DESC").Offset((page - 1) * size).Limit(size).Find(&comments).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out := make([]TrashCommentItem, len(comments))
|
||||||
|
for i, c := range comments {
|
||||||
|
out[i] = TrashCommentItem{Comment: c}
|
||||||
|
if c.DeletedAt.Valid {
|
||||||
|
out[i].DeletedAt = c.DeletedAt.Time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore 从回收站恢复评论及其已软删的回复树
|
||||||
|
func (s *CommentService) Restore(commentID uint) error {
|
||||||
|
var comment model.Comment
|
||||||
|
if err := model.DB.Unscoped().First(&comment, commentID).Error; err != nil {
|
||||||
|
return ErrCommentNotFound
|
||||||
|
}
|
||||||
|
if !comment.DeletedAt.Valid {
|
||||||
|
return errors.New("评论未被删除")
|
||||||
|
}
|
||||||
|
// 所属帖子必须仍存在且未删除
|
||||||
|
var post model.Post
|
||||||
|
if err := model.DB.First(&post, comment.PostID).Error; err != nil {
|
||||||
|
return errors.New("所属帖子不存在或已在回收站,请先恢复帖子")
|
||||||
|
}
|
||||||
|
ids, err := collectReplySubtreeIDs(model.DB, commentID, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return model.DB.Unscoped().Model(&model.Comment{}).
|
||||||
|
Where("id IN ?", ids).
|
||||||
|
Update("deleted_at", nil).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge 永久删除回收站中的评论及其已软删回复(含修订、点赞)
|
||||||
|
func (s *CommentService) Purge(commentID uint) error {
|
||||||
|
var comment model.Comment
|
||||||
|
if err := model.DB.Unscoped().First(&comment, commentID).Error; err != nil {
|
||||||
|
return ErrCommentNotFound
|
||||||
|
}
|
||||||
|
if !comment.DeletedAt.Valid {
|
||||||
|
return errors.New("仅可彻底删除回收站中的评论,请先删除评论")
|
||||||
|
}
|
||||||
|
ids, err := collectReplySubtreeIDs(model.DB, commentID, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return model.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("comment_id IN ?", ids).Delete(&model.CommentRevision{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Where("comment_id IN ?", ids).Delete(&model.CommentLike{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Unscoped().Where("id IN ?", ids).Delete(&model.Comment{}).Error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user