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,169 @@
|
||||
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 { Select } from '@/components/ui/Select';
|
||||
import { Tabs } from '@/components/ui/Tabs';
|
||||
import { JsonViewer } from '@/components/ui/JsonViewer';
|
||||
import { useToast } from '@/context/ToastContext';
|
||||
import { publishImage, publishVideo } from '@/api/endpoints';
|
||||
|
||||
export function PublishPage() {
|
||||
const { toast } = useToast();
|
||||
const [tab, setTab] = useState('image');
|
||||
|
||||
// Image form
|
||||
const [imgTitle, setImgTitle] = useState('');
|
||||
const [imgContent, setImgContent] = useState('');
|
||||
const [imgPaths, setImgPaths] = useState('');
|
||||
const [imgTags, setImgTags] = useState('');
|
||||
const [imgVisibility, setImgVisibility] = useState('public');
|
||||
const [imgOriginal, setImgOriginal] = useState(false);
|
||||
const [imgLoading, setImgLoading] = useState(false);
|
||||
const [imgResult, setImgResult] = useState<unknown>(null);
|
||||
|
||||
// Video form
|
||||
const [vidTitle, setVidTitle] = useState('');
|
||||
const [vidContent, setVidContent] = useState('');
|
||||
const [vidPath, setVidPath] = useState('');
|
||||
const [vidTags, setVidTags] = useState('');
|
||||
const [vidVisibility, setVidVisibility] = useState('public');
|
||||
const [vidLoading, setVidLoading] = useState(false);
|
||||
const [vidResult, setVidResult] = useState<unknown>(null);
|
||||
|
||||
const handlePublishImage = useCallback(async () => {
|
||||
if (!imgTitle.trim() || !imgPaths.trim()) {
|
||||
toast('warning', 'Title and images are required');
|
||||
return;
|
||||
}
|
||||
setImgLoading(true);
|
||||
setImgResult(null);
|
||||
try {
|
||||
const images = imgPaths.split('\n').map((s) => s.trim()).filter(Boolean);
|
||||
const tags = imgTags.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const res = await publishImage({
|
||||
title: imgTitle,
|
||||
content: imgContent,
|
||||
images,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
is_original: imgOriginal,
|
||||
visibility: imgVisibility as 'public' | 'private' | 'friends',
|
||||
});
|
||||
setImgResult(res);
|
||||
if (res.success) {
|
||||
toast('success', 'Image note published!');
|
||||
} else {
|
||||
toast('error', res.error?.message || 'Publish failed');
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Publish failed';
|
||||
toast('error', msg);
|
||||
setImgResult({ error: msg });
|
||||
} finally {
|
||||
setImgLoading(false);
|
||||
}
|
||||
}, [imgTitle, imgContent, imgPaths, imgTags, imgVisibility, imgOriginal, toast]);
|
||||
|
||||
const handlePublishVideo = useCallback(async () => {
|
||||
if (!vidTitle.trim() || !vidPath.trim()) {
|
||||
toast('warning', 'Title and video path are required');
|
||||
return;
|
||||
}
|
||||
setVidLoading(true);
|
||||
setVidResult(null);
|
||||
try {
|
||||
const tags = vidTags.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const res = await publishVideo({
|
||||
title: vidTitle,
|
||||
content: vidContent,
|
||||
video: vidPath,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
visibility: vidVisibility as 'public' | 'private' | 'friends',
|
||||
});
|
||||
setVidResult(res);
|
||||
if (res.success) {
|
||||
toast('success', 'Video note published!');
|
||||
} else {
|
||||
toast('error', res.error?.message || 'Publish failed');
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Publish failed';
|
||||
toast('error', msg);
|
||||
setVidResult({ error: msg });
|
||||
} finally {
|
||||
setVidLoading(false);
|
||||
}
|
||||
}, [vidTitle, vidContent, vidPath, vidTags, vidVisibility, toast]);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<h1 className="text-2xl font-bold">Publish Note</h1>
|
||||
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ key: 'image', label: 'Image Note' },
|
||||
{ key: 'video', label: 'Video Note' },
|
||||
]}
|
||||
active={tab}
|
||||
onChange={setTab}
|
||||
/>
|
||||
|
||||
{tab === 'image' && (
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<Input label="Title" value={imgTitle} onChange={(e) => setImgTitle(e.target.value)} placeholder="Note title" />
|
||||
<Textarea label="Content" value={imgContent} onChange={(e) => setImgContent(e.target.value)} placeholder="Note body text" />
|
||||
<Textarea label="Image Paths (one per line)" value={imgPaths} onChange={(e) => setImgPaths(e.target.value)} placeholder="/path/to/image1.jpg /path/to/image2.jpg" />
|
||||
<Input label="Tags (comma separated)" value={imgTags} onChange={(e) => setImgTags(e.target.value)} placeholder="travel, food" />
|
||||
<div className="flex gap-4 items-end">
|
||||
<Select
|
||||
label="Visibility"
|
||||
options={[
|
||||
{ value: 'public', label: 'Public' },
|
||||
{ value: 'private', label: 'Private' },
|
||||
{ value: 'friends', label: 'Friends' },
|
||||
]}
|
||||
value={imgVisibility}
|
||||
onChange={(e) => setImgVisibility(e.target.value)}
|
||||
/>
|
||||
<label className="flex items-center gap-2 pb-2 cursor-pointer">
|
||||
<input type="checkbox" checked={imgOriginal} onChange={(e) => setImgOriginal(e.target.checked)} className="rounded" />
|
||||
<span className="text-sm text-dark-muted">Original content</span>
|
||||
</label>
|
||||
</div>
|
||||
<Button onClick={() => void handlePublishImage()} loading={imgLoading}>
|
||||
Publish Image Note
|
||||
</Button>
|
||||
{imgResult !== null && <JsonViewer data={imgResult} />}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{tab === 'video' && (
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<Input label="Title" value={vidTitle} onChange={(e) => setVidTitle(e.target.value)} placeholder="Note title" />
|
||||
<Textarea label="Content" value={vidContent} onChange={(e) => setVidContent(e.target.value)} placeholder="Note body text" />
|
||||
<Input label="Video Path" value={vidPath} onChange={(e) => setVidPath(e.target.value)} placeholder="/path/to/video.mp4" />
|
||||
<Input label="Tags (comma separated)" value={vidTags} onChange={(e) => setVidTags(e.target.value)} placeholder="travel, vlog" />
|
||||
<Select
|
||||
label="Visibility"
|
||||
options={[
|
||||
{ value: 'public', label: 'Public' },
|
||||
{ value: 'private', label: 'Private' },
|
||||
{ value: 'friends', label: 'Friends' },
|
||||
]}
|
||||
value={vidVisibility}
|
||||
onChange={(e) => setVidVisibility(e.target.value)}
|
||||
/>
|
||||
<Button onClick={() => void handlePublishVideo()} loading={vidLoading}>
|
||||
Publish Video Note
|
||||
</Button>
|
||||
{vidResult !== null && <JsonViewer data={vidResult} />}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user