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
+135
View File
@@ -0,0 +1,135 @@
import { useHealth } from '@/hooks/useHealth';
import { useLoginStatus } from '@/hooks/useLoginStatus';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Spinner } from '@/components/ui/Spinner';
import { Button } from '@/components/ui/Button';
import { formatUptime } from '@/lib/formatters';
import { useNavigate } from 'react-router-dom';
export function DashboardPage() {
const { health, loading: healthLoading, refresh: refreshHealth } = useHealth(10_000);
const { status: loginStatus, loading: loginLoading } = useLoginStatus();
const navigate = useNavigate();
return (
<div className="space-y-6 max-w-5xl">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Dashboard</h1>
<Button variant="ghost" size="sm" onClick={() => void refreshHealth()}>
Refresh
</Button>
</div>
{/* Status cards row */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Server Status */}
<Card>
<div className="text-xs text-dark-muted uppercase tracking-wider mb-2">Server</div>
{healthLoading ? (
<Spinner size="sm" />
) : health ? (
<div className="space-y-1">
<Badge variant={health.healthy ? 'success' : 'danger'}>
{health.healthy ? 'Healthy' : 'Unhealthy'}
</Badge>
<p className="text-sm text-dark-muted">v{health.version}</p>
</div>
) : (
<Badge variant="danger">Offline</Badge>
)}
</Card>
{/* Uptime */}
<Card>
<div className="text-xs text-dark-muted uppercase tracking-wider mb-2">Uptime</div>
{health ? (
<p className="text-xl font-mono font-bold text-dark-text">{formatUptime(health.uptime)}</p>
) : (
<p className="text-dark-muted">-</p>
)}
</Card>
{/* Login Status */}
<Card>
<div className="text-xs text-dark-muted uppercase tracking-wider mb-2">Xiaohongshu Login</div>
{loginLoading ? (
<Spinner size="sm" />
) : loginStatus ? (
<div className="space-y-1">
<Badge variant={loginStatus.loggedIn ? 'success' : 'warning'}>
{loginStatus.loggedIn ? 'Logged In' : 'Not Logged In'}
</Badge>
{loginStatus.username && (
<p className="text-sm text-dark-muted">{loginStatus.username}</p>
)}
</div>
) : (
<Badge variant="warning">Unknown</Badge>
)}
</Card>
{/* Memory */}
<Card>
<div className="text-xs text-dark-muted uppercase tracking-wider mb-2">Memory</div>
{health ? (
<div className="space-y-1">
<p className="text-xl font-mono font-bold text-dark-text">{health.memory.heapUsed} MB</p>
<p className="text-xs text-dark-muted">of {health.memory.heapTotal} MB heap</p>
</div>
) : (
<p className="text-dark-muted">-</p>
)}
</Card>
</div>
{/* Plugin Health */}
{health && Object.keys(health.plugins).length > 0 && (
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">Plugins</h2>
<div className="space-y-2">
{Object.entries(health.plugins).map(([name, info]) => (
<div key={name} className="flex items-center justify-between py-1">
<span className="text-sm">{name}</span>
<Badge variant={info.healthy ? 'success' : 'danger'}>
{info.healthy ? 'Healthy' : info.message || 'Unhealthy'}
</Badge>
</div>
))}
</div>
</Card>
)}
{/* Quick actions */}
<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 variant="secondary" size="sm" onClick={() => navigate('/login')}>
Manage Login
</Button>
<Button variant="secondary" size="sm" onClick={() => navigate('/browser')}>
Browse Content
</Button>
<Button variant="secondary" size="sm" onClick={() => navigate('/publish')}>
Publish Note
</Button>
<Button variant="secondary" size="sm" onClick={() => navigate('/api-tester')}>
API Tester
</Button>
</div>
</Card>
{/* Raw health data */}
{health && (
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">
Raw Health Data
</h2>
<pre className="bg-dark-bg border border-dark-border rounded-lg p-4 text-xs text-dark-text overflow-auto font-mono max-h-64">
{JSON.stringify(health, null, 2)}
</pre>
</Card>
)}
</div>
);
}