改进MCP发布体验:URL媒体下载、内容限制校验、发布返回笔记链接、新增查看已发布笔记工具;整合发布入口至小红书页面modal;端口统一改为9527;新增pnpm run restart脚本

This commit is contained in:
2026-03-02 10:48:30 +08:00
parent 7661a723ea
commit def0828815
17 changed files with 529 additions and 278 deletions
-169
View File
@@ -1,169 +0,0 @@
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', '标题和图片为必填项');
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', '图文笔记发布成功!');
} else {
toast('error', res.error?.message || '发布失败');
}
} catch (err) {
const msg = err instanceof Error ? err.message : '发布失败';
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', '标题和视频路径为必填项');
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', '视频笔记发布成功!');
} else {
toast('error', res.error?.message || '发布失败');
}
} catch (err) {
const msg = err instanceof Error ? err.message : '发布失败';
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"></h1>
<Tabs
tabs={[
{ key: 'image', label: '图文笔记' },
{ key: 'video', label: '视频笔记' },
]}
active={tab}
onChange={setTab}
/>
{tab === 'image' && (
<Card>
<div className="space-y-4">
<Input label="标题" value={imgTitle} onChange={(e) => setImgTitle(e.target.value)} placeholder="笔记标题" />
<Textarea label="正文" value={imgContent} onChange={(e) => setImgContent(e.target.value)} placeholder="笔记正文" />
<Textarea label="图片路径(每行一个)" value={imgPaths} onChange={(e) => setImgPaths(e.target.value)} placeholder="/path/to/image1.jpg&#10;/path/to/image2.jpg" />
<Input label="标签(逗号分隔)" value={imgTags} onChange={(e) => setImgTags(e.target.value)} placeholder="旅行, 美食" />
<div className="flex gap-4 items-end">
<Select
label="可见性"
options={[
{ value: 'public', label: '公开' },
{ value: 'private', label: '私密' },
{ value: 'friends', label: '仅好友' },
]}
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"></span>
</label>
</div>
<Button onClick={() => void handlePublishImage()} loading={imgLoading}>
</Button>
{imgResult !== null && <JsonViewer data={imgResult} />}
</div>
</Card>
)}
{tab === 'video' && (
<Card>
<div className="space-y-4">
<Input label="标题" value={vidTitle} onChange={(e) => setVidTitle(e.target.value)} placeholder="笔记标题" />
<Textarea label="正文" value={vidContent} onChange={(e) => setVidContent(e.target.value)} placeholder="笔记正文" />
<Input label="视频路径" value={vidPath} onChange={(e) => setVidPath(e.target.value)} placeholder="/path/to/video.mp4" />
<Input label="标签(逗号分隔)" value={vidTags} onChange={(e) => setVidTags(e.target.value)} placeholder="旅行, vlog" />
<Select
label="可见性"
options={[
{ value: 'public', label: '公开' },
{ value: 'private', label: '私密' },
{ value: 'friends', label: '仅好友' },
]}
value={vidVisibility}
onChange={(e) => setVidVisibility(e.target.value)}
/>
<Button onClick={() => void handlePublishVideo()} loading={vidLoading}>
</Button>
{vidResult !== null && <JsonViewer data={vidResult} />}
</div>
</Card>
)}
</div>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ export function SettingsPage() {
/>
<p className="text-xs text-dark-muted">
Dashboard Express {' '}
<code className="text-dark-accent">http://192.168.1.100:3000</code> 用于远程服务器。
<code className="text-dark-accent">http://192.168.1.100:9527</code> 用于远程服务器。
</p>
</div>
</Card>
+8
View File
@@ -7,6 +7,7 @@ import { Spinner } from '@/components/ui/Spinner';
import { FeedGrid } from '@/components/feed/FeedGrid';
import { FeedDetail } from '@/components/feed/FeedDetail';
import { UserCard } from '@/components/feed/UserCard';
import { PublishModal } from '@/components/feed/PublishModal';
import { useAuth } from '@/context/AuthContext';
import { useToast } from '@/context/ToastContext';
import { useLoginStatus } from '@/hooks/useLoginStatus';
@@ -134,6 +135,7 @@ export function XiaohongshuPage() {
const [searchLoading, setSearchLoading] = useState(false);
const [selectedFeed, setSelectedFeed] = useState<{ id: string; xsecToken: string } | null>(null);
const [userView, setUserView] = useState<{ userId: string; xsecToken: string } | null>(null);
const [publishOpen, setPublishOpen] = useState(false);
const loadFeed = useCallback(async () => {
setFeedsLoading(true);
@@ -226,6 +228,9 @@ export function XiaohongshuPage() {
</Button>
)}
<Button size="sm" variant="secondary" onClick={() => setPublishOpen(true)}>
</Button>
</div>
)}
@@ -351,6 +356,9 @@ export function XiaohongshuPage() {
/>
)}
{/* ── Publish modal ── */}
{publishOpen && <PublishModal onClose={() => setPublishOpen(false)} />}
{/* ── User profile slide-over ── */}
{userView && (
<div className="fixed inset-0 z-50 flex">