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:
@@ -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">×</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user