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
+174
View File
@@ -0,0 +1,174 @@
import { useState, useEffect } from 'react';
import type { FeedDetail as FeedDetailType } from '@/api/types';
import { getFeedDetail } from '@/api/endpoints';
import { Badge } from '@/components/ui/Badge';
import { Spinner } from '@/components/ui/Spinner';
import { Button } from '@/components/ui/Button';
import { CommentTree } from './CommentTree';
import { formatNumber, formatTime } from '@/lib/formatters';
interface Props {
feedId: string;
xsecToken: string;
onClose: () => void;
onUserClick?: (userId: string, xsecToken: string) => void;
}
export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
const [detail, setDetail] = useState<FeedDetailType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentImage, setCurrentImage] = useState(0);
useEffect(() => {
setLoading(true);
setError(null);
void getFeedDetail(feedId, xsecToken)
.then((res) => {
if (res.success && res.data) {
setDetail(res.data);
} else {
setError(res.error?.message || 'Failed to load detail');
}
})
.catch((err) => setError(err instanceof Error ? err.message : 'Error'))
.finally(() => setLoading(false));
}, [feedId, xsecToken]);
return (
<div className="fixed inset-0 z-50 flex">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative ml-auto w-full max-w-2xl bg-dark-card border-l border-dark-border overflow-y-auto">
<div className="sticky top-0 bg-dark-card border-b border-dark-border px-5 py-3 flex items-center justify-between z-10">
<h3 className="font-semibold truncate">Feed Detail</h3>
<button onClick={onClose} className="text-dark-muted hover:text-dark-text text-xl">&times;</button>
</div>
{loading && (
<div className="flex justify-center py-20"><Spinner size="lg" /></div>
)}
{error && (
<div className="p-5 text-dark-danger">{error}</div>
)}
{detail && (
<div className="p-5 space-y-5">
{/* Images */}
{detail.images.length > 0 && (
<div>
<div className="rounded-xl overflow-hidden bg-dark-bg">
<img
src={detail.images[currentImage]}
alt=""
className="w-full max-h-96 object-contain"
/>
</div>
{detail.images.length > 1 && (
<div className="flex gap-2 mt-2 overflow-x-auto pb-1">
{detail.images.map((img, i) => (
<button
key={i}
onClick={() => setCurrentImage(i)}
className={`w-14 h-14 rounded-lg overflow-hidden shrink-0 border-2 ${
i === currentImage ? 'border-dark-accent' : 'border-transparent'
}`}
>
<img src={img} alt="" className="w-full h-full object-cover" />
</button>
))}
</div>
)}
</div>
)}
{/* Video */}
{detail.videoUrl && (
<div className="rounded-xl overflow-hidden bg-dark-bg p-4">
<Badge variant="info">Video Note</Badge>
<p className="text-xs text-dark-muted mt-2 break-all">{detail.videoUrl}</p>
</div>
)}
{/* Title & Content */}
<div>
<h2 className="text-xl font-bold mb-2">{detail.title}</h2>
<p className="text-sm text-dark-text/80 whitespace-pre-wrap">{detail.description}</p>
</div>
{/* Tags */}
{detail.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{detail.tags.map((tag) => (
<Badge key={tag} variant="info">#{tag}</Badge>
))}
</div>
)}
{/* Stats */}
<div className="grid grid-cols-4 gap-3">
{[
{ label: 'Likes', value: detail.likeCount },
{ label: 'Collects', value: detail.collectCount },
{ label: 'Comments', value: detail.commentCount },
{ label: 'Shares', value: detail.shareCount },
].map((s) => (
<div key={s.label} className="bg-dark-bg rounded-lg p-3 text-center">
<p className="text-lg font-bold">{formatNumber(s.value)}</p>
<p className="text-xs text-dark-muted">{s.label}</p>
</div>
))}
</div>
{/* Author */}
<div
className="flex items-center gap-3 p-3 bg-dark-bg rounded-lg cursor-pointer hover:bg-dark-hover"
onClick={() => onUserClick?.(detail.user.id, detail.xsecToken)}
>
{detail.user.avatar && (
<img src={detail.user.avatar} alt="" className="w-10 h-10 rounded-full" />
)}
<div>
<p className="text-sm font-medium">{detail.user.nickname}</p>
<p className="text-xs text-dark-muted">{detail.ipLocation} · {formatTime(detail.createTime)}</p>
</div>
</div>
{/* IDs for interaction */}
<div className="bg-dark-bg rounded-lg p-3 text-xs space-y-1">
<p><span className="text-dark-muted">Feed ID:</span> <code className="text-dark-accent">{detail.id}</code></p>
<p><span className="text-dark-muted">xsec_token:</span> <code className="text-dark-accent">{detail.xsecToken}</code></p>
<p><span className="text-dark-muted">User ID:</span> <code className="text-dark-accent">{detail.user.id}</code></p>
<div className="flex gap-2 mt-2">
<Button
size="sm"
variant="ghost"
onClick={() => void navigator.clipboard.writeText(detail.id)}
>
Copy Feed ID
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => void navigator.clipboard.writeText(detail.xsecToken)}
>
Copy Token
</Button>
</div>
</div>
{/* Comments */}
{detail.comments.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">
Comments ({detail.comments.length})
</h3>
<CommentTree comments={detail.comments} />
</div>
)}
</div>
)}
</div>
</div>
);
}