feat: 添加 Admin Dashboard — React 19 SPA,包含 7 个页面

- Dashboard: 健康状态轮询、状态卡片、内存统计、快捷操作
- Login: 二维码展示 + 3 秒自动轮询 + 倒计时 + 登出
- Browser: 探索/搜索/用户三标签页,Feed 网格、详情面板、评论树
- Publish: 图文/视频发布表单,支持标签、可见性、定时发布
- Interactions: 点赞/取消点赞、收藏、评论、回复 + 操作日志
- API Tester: 端点选择器、请求体编辑器、cURL 生成、响应查看、历史记录
- Settings: Token 配置、服务器 URL 设置

后端改动:
- app.ts: 生产环境提供 dist/web/ 静态文件服务 + SPA fallback
- Dockerfile: 添加 web 构建阶段
- package.json: 添加 build:web、build:all、dev:web 脚本

技术栈: React 19 + TypeScript + Vite 6 + Tailwind CSS(暗色主题)
产物: 85.5 KB gzip JS + 4 KB gzip CSS,零重型依赖
This commit is contained in:
2026-03-01 13:58:55 +08:00
parent 6d35387e2b
commit c6a8177718
51 changed files with 5665 additions and 1 deletions
+204
View File
@@ -0,0 +1,204 @@
import { useState, useCallback } from 'react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Textarea } from '@/components/ui/Textarea';
import { JsonViewer } from '@/components/ui/JsonViewer';
import { useToast } from '@/context/ToastContext';
import { toggleLike, toggleFavorite, postComment, replyComment } from '@/api/endpoints';
interface LogEntry {
id: number;
action: string;
time: string;
result: unknown;
}
let logId = 0;
export function InteractionsPage() {
const { toast } = useToast();
const [feedId, setFeedId] = useState('');
const [xsecToken, setXsecToken] = useState('');
const [loading, setLoading] = useState<string | null>(null);
const [log, setLog] = useState<LogEntry[]>([]);
const addLog = (action: string, result: unknown) => {
setLog((prev) => [{ id: logId++, action, time: new Date().toLocaleTimeString(), result }, ...prev].slice(0, 50));
};
// Comment state
const [commentText, setCommentText] = useState('');
// Reply state
const [replyText, setReplyText] = useState('');
const [replyCommentId, setReplyCommentId] = useState('');
const [replyUserId, setReplyUserId] = useState('');
const checkIds = () => {
if (!feedId.trim() || !xsecToken.trim()) {
toast('warning', 'Feed ID and xsec_token are required');
return false;
}
return true;
};
const handleLike = useCallback(async (unlike: boolean) => {
if (!checkIds()) return;
setLoading(unlike ? 'unlike' : 'like');
try {
const res = await toggleLike(feedId, xsecToken, unlike);
addLog(unlike ? 'Unlike' : 'Like', res);
toast('success', unlike ? 'Unliked' : 'Liked');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed';
addLog(unlike ? 'Unlike' : 'Like', { error: msg });
toast('error', msg);
} finally {
setLoading(null);
}
}, [feedId, xsecToken, toast]);
const handleFavorite = useCallback(async (unfavorite: boolean) => {
if (!checkIds()) return;
setLoading(unfavorite ? 'unfavorite' : 'favorite');
try {
const res = await toggleFavorite(feedId, xsecToken, unfavorite);
addLog(unfavorite ? 'Unfavorite' : 'Favorite', res);
toast('success', unfavorite ? 'Unfavorited' : 'Favorited');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed';
addLog(unfavorite ? 'Unfavorite' : 'Favorite', { error: msg });
toast('error', msg);
} finally {
setLoading(null);
}
}, [feedId, xsecToken, toast]);
const handleComment = useCallback(async () => {
if (!checkIds() || !commentText.trim()) {
toast('warning', 'Comment text is required');
return;
}
setLoading('comment');
try {
const res = await postComment(feedId, xsecToken, commentText);
addLog('Comment', res);
toast('success', 'Comment posted');
setCommentText('');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed';
addLog('Comment', { error: msg });
toast('error', msg);
} finally {
setLoading(null);
}
}, [feedId, xsecToken, commentText, toast]);
const handleReply = useCallback(async () => {
if (!checkIds() || !replyText.trim()) {
toast('warning', 'Reply text is required');
return;
}
setLoading('reply');
try {
const res = await replyComment({
feed_id: feedId,
xsec_token: xsecToken,
content: replyText,
comment_id: replyCommentId || undefined,
user_id: replyUserId || undefined,
});
addLog('Reply', res);
toast('success', 'Reply posted');
setReplyText('');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed';
addLog('Reply', { error: msg });
toast('error', msg);
} finally {
setLoading(null);
}
}, [feedId, xsecToken, replyText, replyCommentId, replyUserId, toast]);
return (
<div className="max-w-3xl space-y-6">
<h1 className="text-2xl font-bold">Interactions</h1>
{/* Target */}
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">Target Note</h2>
<div className="grid grid-cols-2 gap-4">
<Input label="Feed ID" value={feedId} onChange={(e) => setFeedId(e.target.value)} placeholder="Feed ID" />
<Input label="xsec_token" value={xsecToken} onChange={(e) => setXsecToken(e.target.value)} placeholder="xsec_token" />
</div>
</Card>
{/* Like / Favorite */}
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">Quick Actions</h2>
<div className="flex flex-wrap gap-3">
<Button onClick={() => void handleLike(false)} loading={loading === 'like'} size="sm">
Like
</Button>
<Button onClick={() => void handleLike(true)} loading={loading === 'unlike'} variant="secondary" size="sm">
Unlike
</Button>
<Button onClick={() => void handleFavorite(false)} loading={loading === 'favorite'} size="sm">
Favorite
</Button>
<Button onClick={() => void handleFavorite(true)} loading={loading === 'unfavorite'} variant="secondary" size="sm">
Unfavorite
</Button>
</div>
</Card>
{/* Comment */}
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">Post Comment</h2>
<div className="space-y-3">
<Textarea value={commentText} onChange={(e) => setCommentText(e.target.value)} placeholder="Write a comment..." />
<Button onClick={() => void handleComment()} loading={loading === 'comment'} size="sm">
Post Comment
</Button>
</div>
</Card>
{/* Reply */}
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">Reply to Comment</h2>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<Input label="Comment ID" value={replyCommentId} onChange={(e) => setReplyCommentId(e.target.value)} placeholder="Optional" />
<Input label="User ID" value={replyUserId} onChange={(e) => setReplyUserId(e.target.value)} placeholder="Optional" />
</div>
<Textarea value={replyText} onChange={(e) => setReplyText(e.target.value)} placeholder="Write a reply..." />
<Button onClick={() => void handleReply()} loading={loading === 'reply'} size="sm">
Post Reply
</Button>
</div>
</Card>
{/* Log */}
{log.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider">Action Log</h2>
<Button variant="ghost" size="sm" onClick={() => setLog([])}>Clear</Button>
</div>
<div className="space-y-3 max-h-96 overflow-y-auto">
{log.map((entry) => (
<div key={entry.id} className="border-b border-dark-border/50 pb-2 last:border-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-dark-muted">{entry.time}</span>
<span className="text-sm font-medium text-dark-accent">{entry.action}</span>
</div>
<JsonViewer data={entry.result} collapsed maxHeight="120px" />
</div>
))}
</div>
</Card>
)}
</div>
);
}