改进MCP发布体验:URL媒体下载、内容限制校验、发布返回笔记链接、新增查看已发布笔记工具;整合发布入口至小红书页面modal;端口统一改为9527;新增pnpm run restart脚本
This commit is contained in:
@@ -4,7 +4,6 @@ import { ToastProvider } from '@/context/ToastContext';
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import { DashboardPage } from '@/pages/DashboardPage';
|
||||
import { XiaohongshuPage } from '@/pages/XiaohongshuPage';
|
||||
import { PublishPage } from '@/pages/PublishPage';
|
||||
import { InteractionsPage } from '@/pages/InteractionsPage';
|
||||
import { ApiTesterPage } from '@/pages/ApiTesterPage';
|
||||
import { SettingsPage } from '@/pages/SettingsPage';
|
||||
@@ -18,7 +17,6 @@ export default function App() {
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="xhs" element={<XiaohongshuPage />} />
|
||||
<Route path="publish" element={<PublishPage />} />
|
||||
<Route path="interactions" element={<InteractionsPage />} />
|
||||
<Route path="api-tester" element={<ApiTesterPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
|
||||
@@ -61,7 +61,7 @@ export function generateCurl(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): string {
|
||||
const baseUrl = getBaseUrl() || 'http://127.0.0.1:3000';
|
||||
const baseUrl = getBaseUrl() || 'http://127.0.0.1:9527';
|
||||
const token = getToken();
|
||||
const parts = [`curl -X ${method}`];
|
||||
parts.push(`'${baseUrl}${path}'`);
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
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 { useToast } from '@/context/ToastContext';
|
||||
import { publishImage, publishVideo } from '@/api/endpoints';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type TabKey = 'image' | 'video';
|
||||
|
||||
export function PublishModal({ onClose }: Props) {
|
||||
const { toast } = useToast();
|
||||
const [tab, setTab] = useState<TabKey>('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);
|
||||
|
||||
// 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 handlePublishImage = useCallback(async () => {
|
||||
if (!imgTitle.trim() || !imgPaths.trim()) {
|
||||
toast('warning', '标题和图片路径为必填项');
|
||||
return;
|
||||
}
|
||||
setImgLoading(true);
|
||||
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',
|
||||
});
|
||||
if (res.success) {
|
||||
toast('success', '图文笔记发布成功!');
|
||||
onClose();
|
||||
} else {
|
||||
toast('error', res.error?.message || '发布失败');
|
||||
}
|
||||
} catch (err) {
|
||||
toast('error', err instanceof Error ? err.message : '发布失败');
|
||||
} finally {
|
||||
setImgLoading(false);
|
||||
}
|
||||
}, [imgTitle, imgContent, imgPaths, imgTags, imgVisibility, imgOriginal, toast, onClose]);
|
||||
|
||||
const handlePublishVideo = useCallback(async () => {
|
||||
if (!vidTitle.trim() || !vidPath.trim()) {
|
||||
toast('warning', '标题和视频路径为必填项');
|
||||
return;
|
||||
}
|
||||
setVidLoading(true);
|
||||
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',
|
||||
});
|
||||
if (res.success) {
|
||||
toast('success', '视频笔记发布成功!');
|
||||
onClose();
|
||||
} else {
|
||||
toast('error', res.error?.message || '发布失败');
|
||||
}
|
||||
} catch (err) {
|
||||
toast('error', err instanceof Error ? err.message : '发布失败');
|
||||
} finally {
|
||||
setVidLoading(false);
|
||||
}
|
||||
}, [vidTitle, vidContent, vidPath, vidTags, vidVisibility, toast, onClose]);
|
||||
|
||||
const isLoading = imgLoading || vidLoading;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||
<div className="relative w-full max-w-lg bg-dark-card border border-dark-border rounded-2xl shadow-2xl overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-dark-border">
|
||||
<h2 className="font-semibold text-dark-text">发布笔记</h2>
|
||||
<button onClick={onClose} className="text-dark-muted hover:text-dark-text text-xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-dark-border">
|
||||
{(['image', 'video'] as TabKey[]).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
className={`flex-1 py-2.5 text-sm font-medium transition-colors ${
|
||||
tab === key
|
||||
? 'text-dark-accent border-b-2 border-dark-accent'
|
||||
: 'text-dark-muted hover:text-dark-text'
|
||||
}`}
|
||||
>
|
||||
{key === 'image' ? '图文笔记' : '视频笔记'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="p-5 space-y-4 max-h-[65vh] overflow-y-auto">
|
||||
{tab === 'image' ? (
|
||||
<>
|
||||
<Input label="标题" value={imgTitle} onChange={(e) => setImgTitle(e.target.value)} placeholder="笔记标题(必填)" />
|
||||
<Textarea label="正文" value={imgContent} onChange={(e) => setImgContent(e.target.value)} placeholder="笔记正文" rows={3} />
|
||||
<Textarea label="图片路径(每行一个)" value={imgPaths} onChange={(e) => setImgPaths(e.target.value)} placeholder={'/path/to/image1.jpg\n/path/to/image2.jpg'} rows={3} />
|
||||
<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 shrink-0">
|
||||
<input type="checkbox" checked={imgOriginal} onChange={(e) => setImgOriginal(e.target.checked)} className="rounded" />
|
||||
<span className="text-sm text-dark-muted">原创声明</span>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input label="标题" value={vidTitle} onChange={(e) => setVidTitle(e.target.value)} placeholder="笔记标题(必填)" />
|
||||
<Textarea label="正文" value={vidContent} onChange={(e) => setVidContent(e.target.value)} placeholder="笔记正文" rows={3} />
|
||||
<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)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 px-5 py-4 border-t border-dark-border">
|
||||
<Button variant="ghost" size="sm" onClick={onClose} disabled={isLoading}>取消</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void (tab === 'image' ? handlePublishImage() : handlePublishVideo())}
|
||||
loading={isLoading}
|
||||
>
|
||||
发布
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
export const NAV_ITEMS = [
|
||||
{ path: '/', label: '仪表盘', icon: 'dashboard' },
|
||||
{ path: '/xhs', label: '小红书', icon: 'xhs' },
|
||||
{ path: '/publish', label: '发布', icon: 'publish' },
|
||||
{ path: '/interactions', label: '互动', icon: 'interactions' },
|
||||
{ path: '/api-tester', label: 'API 测试', icon: 'api' },
|
||||
{ path: '/settings', label: '设置', icon: 'settings' },
|
||||
|
||||
@@ -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 /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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
+2
-2
@@ -13,11 +13,11 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:3000',
|
||||
target: 'http://127.0.0.1:9527',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/health': {
|
||||
target: 'http://127.0.0.1:3000',
|
||||
target: 'http://127.0.0.1:9527',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user