diff --git a/package.json b/package.json index 9de66b6..6363fff 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,11 @@ }, "scripts": { "build": "tsup", - "build:web": "cd web && npm run build && mkdir -p ../dist/web && cp -r dist/* ../dist/web/", - "build:all": "rm -rf dist/index.js dist/index.js.map && npm run build && npm run build:web", + "build:web": "cd web && pnpm build && mkdir -p ../dist/web && cp -r dist/* ../dist/web/", + "build:all": "pnpm build && pnpm build:web", + "restart": "pnpm build:all && pkill -f 'node dist/index.js' ; sleep 1 && node dist/index.js &", "dev": "tsup --watch", - "dev:web": "cd web && npm run dev", + "dev:web": "cd web && pnpm dev", "start": "node dist/index.js", "test": "vitest run", "test:watch": "vitest", diff --git a/src/config/index.ts b/src/config/index.ts index 576f74a..d2072cb 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -100,7 +100,7 @@ export interface AppConfig { // --------------------------------------------------------------------------- export const config: AppConfig = { - port: envInt('PORT', 3000), + port: envInt('PORT', 9527), host, headless: envBool('HEADLESS', true), browserBin: process.env['BROWSER_BIN'] || undefined, diff --git a/src/platforms/xiaohongshu/index.ts b/src/platforms/xiaohongshu/index.ts index b498f50..d83d80c 100644 --- a/src/platforms/xiaohongshu/index.ts +++ b/src/platforms/xiaohongshu/index.ts @@ -4,7 +4,7 @@ import type { Router } from 'express'; import type { BrowserManager } from '../../browser/manager.js'; import { config } from '../../config/index.js'; import { withErrorHandling } from '../../utils/errors.js'; -import { validateMediaPath } from '../../utils/downloader.js'; +import { resolveMediaInput, cleanupFile } from '../../utils/downloader.js'; import { checkLoginStatus, getLoginQRCode, deleteCookies } from './login.js'; import { listFeeds } from './feeds.js'; import { searchFeeds } from './search.js'; @@ -12,6 +12,7 @@ import { getFeedDetail } from './feed-detail.js'; import { getUserProfile } from './user-profile.js'; import { publishImageNote } from './publish.js'; import { publishVideoNote } from './publish-video.js'; +import { listMyNotes } from './my-notes.js'; import { postComment, replyComment } from './comment.js'; import { toggleLike, toggleFavorite } from './interaction.js'; import { createXhsRoutes } from './routes.js'; @@ -25,6 +26,7 @@ import { GetUserProfileSchema, PublishImageSchema, PublishVideoSchema, + ListMyNotesSchema, PostCommentSchema, ReplyCommentSchema, LikeSchema, @@ -287,6 +289,38 @@ export const xiaohongshuPlugin: PlatformPlugin = { }, ); + // ===================================================================== + // My published notes (1 tool) + // ===================================================================== + + // ----------------------------------------------------------------------- + // xhs_list_my_notes + // ----------------------------------------------------------------------- + + server.tool( + 'xhs_list_my_notes', + 'List your published notes on Xiaohongshu from the creator center', + ListMyNotesSchema, + async () => { + return withErrorHandling('xhs_list_my_notes', async () => { + const timeoutMs = + config.operationTimeouts['feed_list'] ?? + config.operationTimeouts['default'] ?? + 60_000; + + const notes = await browser.withPage( + PLATFORM, + async (page) => listMyNotes(page), + timeoutMs, + ); + + return { + content: [{ type: 'text' as const, text: JSON.stringify(notes) }], + }; + }); + }, + ); + // ===================================================================== // Phase 4: Content publishing (2 tools) // ===================================================================== @@ -301,40 +335,39 @@ export const xiaohongshuPlugin: PlatformPlugin = { PublishImageSchema, async (args) => { return withErrorHandling('xhs_publish_image', async () => { - // Fail fast: validate all image paths BEFORE acquiring a browser page. - const validatedPaths: string[] = []; - for (const imagePath of args.images) { - const resolved = await validateMediaPath(imagePath, { - maxSizeMB: IMAGE_MAX_SIZE_MB, - }); - validatedPaths.push(resolved); + // Resolve all images (local path or URL download) before acquiring browser. + const resolved: Array<{ path: string; temporary: boolean }> = []; + for (const img of args.images) { + resolved.push(await resolveMediaInput(img, { maxSizeMB: IMAGE_MAX_SIZE_MB })); } + const validatedPaths = resolved.map((r) => r.path); const timeoutMs = config.operationTimeouts['publish'] ?? config.operationTimeouts['default'] ?? 300_000; - const result = await browser.withPage( - PLATFORM, - async (page) => - publishImageNote(page, args.title, args.content, validatedPaths, { - tags: args.tags, - scheduleAt: args.schedule_at, - isOriginal: args.is_original, - visibility: args.visibility, - }), - timeoutMs, - ); + try { + const result = await browser.withPage( + PLATFORM, + async (page) => + publishImageNote(page, args.title, args.content, validatedPaths, { + tags: args.tags, + scheduleAt: args.schedule_at, + isOriginal: args.is_original, + visibility: args.visibility, + }), + timeoutMs, + ); - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify(result), - }, - ], - }; + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + }; + } finally { + for (const r of resolved) { + if (r.temporary) await cleanupFile(r.path).catch(() => undefined); + } + } }); }, ); @@ -349,35 +382,32 @@ export const xiaohongshuPlugin: PlatformPlugin = { PublishVideoSchema, async (args) => { return withErrorHandling('xhs_publish_video', async () => { - // Fail fast: validate the video path BEFORE acquiring a browser page. - const validatedPath = await validateMediaPath(args.video, { - maxSizeMB: VIDEO_MAX_SIZE_MB, - }); + // Resolve video (local path or URL download) before acquiring browser. + const resolvedVideo = await resolveMediaInput(args.video, { maxSizeMB: VIDEO_MAX_SIZE_MB }); const timeoutMs = config.operationTimeouts['publish'] ?? config.operationTimeouts['default'] ?? 300_000; - const result = await browser.withPage( - PLATFORM, - async (page) => - publishVideoNote(page, args.title, args.content, validatedPath, { - tags: args.tags, - scheduleAt: args.schedule_at, - visibility: args.visibility, - }), - timeoutMs, - ); + try { + const result = await browser.withPage( + PLATFORM, + async (page) => + publishVideoNote(page, args.title, args.content, resolvedVideo.path, { + tags: args.tags, + scheduleAt: args.schedule_at, + visibility: args.visibility, + }), + timeoutMs, + ); - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify(result), - }, - ], - }; + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + }; + } finally { + if (resolvedVideo.temporary) await cleanupFile(resolvedVideo.path).catch(() => undefined); + } }); }, ); diff --git a/src/platforms/xiaohongshu/my-notes.ts b/src/platforms/xiaohongshu/my-notes.ts new file mode 100644 index 0000000..b07f922 --- /dev/null +++ b/src/platforms/xiaohongshu/my-notes.ts @@ -0,0 +1,123 @@ +import type { Page } from 'rebrowser-playwright'; + +import { logger } from '../../utils/logger.js'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const MAIN_EXPLORE_URL = 'https://www.xiaohongshu.com/explore'; +const CREATOR_WORKS_URL = 'https://creator.xiaohongshu.com/publish/works'; + +const log = logger.child({ module: 'xhs-my-notes' }); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface MyNote { + noteId: string; + title: string; + coverUrl?: string; + noteUrl: string; + type: 'image' | 'video' | 'unknown'; + publishTime?: string; + likeCount?: number; + commentCount?: number; + collectCount?: number; + viewCount?: number; +} + +// --------------------------------------------------------------------------- +// listMyNotes +// --------------------------------------------------------------------------- + +/** + * Navigate to the creator center works page and extract the list of published notes. + */ +export async function listMyNotes(page: Page): Promise { + log.info('Navigating to creator works page'); + + // Establish session on main site first (same pattern as publish). + await page.goto(MAIN_EXPLORE_URL, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1_000); + + await page.goto(CREATOR_WORKS_URL, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2_000); + + if (page.url().includes('/login')) { + throw new Error('Creator center redirected to login — cookies may be expired'); + } + + // Wait for note items to appear. + await page.waitForSelector('.works-item, .note-item, [class*="works"], [class*="note-card"]', { + timeout: 15_000, + }).catch(() => { + log.debug('Note list selector not found, attempting extraction anyway'); + }); + + await page.waitForTimeout(1_000); + + const notes = await page.evaluate((): MyNote[] => { + const results: MyNote[] = []; + + // Try multiple selector patterns for different creator center versions. + const candidates = Array.from( + document.querySelectorAll('.works-item, .note-item, [class*="works-item"], [class*="note-card"]'), + ); + + for (const el of candidates) { + // Extract note ID from link href. + const link = el.querySelector('a[href*="/explore/"], a[href*="/note/"]') as HTMLAnchorElement | null; + const href = link?.href ?? ''; + + const idMatch = href.match(/\/explore\/([a-f0-9]+)|\/note\/([a-f0-9]+)/); + const noteId = idMatch?.[1] ?? idMatch?.[2] ?? ''; + if (!noteId) continue; + + const noteUrl = `https://www.xiaohongshu.com/explore/${noteId}`; + + // Title. + const titleEl = el.querySelector('.title, [class*="title"], h3, h4'); + const title = titleEl?.textContent?.trim() ?? ''; + + // Cover image. + const imgEl = el.querySelector('img') as HTMLImageElement | null; + const coverUrl = imgEl?.src ?? undefined; + + // Note type — look for video indicator. + const hasVideo = el.querySelector('[class*="video"], video, [class*="play"]') !== null; + const type: 'image' | 'video' | 'unknown' = hasVideo ? 'video' : title || coverUrl ? 'image' : 'unknown'; + + // Stats — try to find numeric values in stat spans. + const statEls = Array.from(el.querySelectorAll('[class*="count"], [class*="stat"], [class*="data"]')); + const nums = statEls.map((s) => { + const t = s.textContent?.trim() ?? ''; + const n = parseFloat(t.replace(/[万w]/i, '0000').replace(/[,,]/g, '')); + return Number.isNaN(n) ? undefined : n; + }); + + // Publish time. + const timeEl = el.querySelector('time, [class*="time"], [class*="date"]'); + const publishTime = timeEl?.textContent?.trim() ?? undefined; + + results.push({ + noteId, + title, + coverUrl, + noteUrl, + type, + publishTime, + likeCount: nums[0], + commentCount: nums[1], + collectCount: nums[2], + viewCount: nums[3], + }); + } + + return results; + }); + + log.info({ count: notes.length }, 'My notes extracted'); + return notes; +} diff --git a/src/platforms/xiaohongshu/publish-video.ts b/src/platforms/xiaohongshu/publish-video.ts index d612e80..7f05d7a 100644 --- a/src/platforms/xiaohongshu/publish-video.ts +++ b/src/platforms/xiaohongshu/publish-video.ts @@ -54,7 +54,7 @@ export async function publishVideoNote( content: string, videoPath: string, options?: PublishVideoOptions, -): Promise<{ success: boolean; noteId?: string }> { +): Promise<{ success: boolean; noteId?: string; noteUrl?: string }> { log.info( { hasOptions: !!options }, 'Starting video note publish', @@ -260,7 +260,7 @@ async function setSchedule(page: Page, scheduleAt: string): Promise { */ async function waitForPublishResult( page: Page, -): Promise<{ success: boolean; noteId?: string }> { +): Promise<{ success: boolean; noteId?: string; noteUrl?: string }> { const urlChangePromise = page .waitForURL(sel.publishSuccessUrlPattern, { timeout: 30_000 }) .then(() => true) @@ -290,8 +290,9 @@ async function waitForPublishResult( } const noteId = extractNoteIdFromUrl(page.url()); + const noteUrl = noteId ? `https://www.xiaohongshu.com/explore/${noteId}` : undefined; - return { success: true, noteId }; + return { success: true, noteId, noteUrl }; } /** diff --git a/src/platforms/xiaohongshu/publish.ts b/src/platforms/xiaohongshu/publish.ts index a04e55e..3708f59 100644 --- a/src/platforms/xiaohongshu/publish.ts +++ b/src/platforms/xiaohongshu/publish.ts @@ -56,7 +56,7 @@ export async function publishImageNote( content: string, imagePaths: string[], options?: PublishImageOptions, -): Promise<{ success: boolean; noteId?: string }> { +): Promise<{ success: boolean; noteId?: string; noteUrl?: string }> { log.info( { imageCount: imagePaths.length, hasOptions: !!options }, 'Starting image note publish', @@ -354,7 +354,7 @@ async function setSchedule(page: Page, scheduleAt: string): Promise { */ async function waitForPublishResult( page: Page, -): Promise<{ success: boolean; noteId?: string }> { +): Promise<{ success: boolean; noteId?: string; noteUrl?: string }> { // Strategy 1: Wait for the URL to change to a success page. // Strategy 2: Wait for a success element to appear. // Use Promise.all so both run concurrently. @@ -391,8 +391,9 @@ async function waitForPublishResult( // Try to extract the note ID from the current URL if available. const noteId = extractNoteIdFromUrl(page.url()); + const noteUrl = noteId ? `https://www.xiaohongshu.com/explore/${noteId}` : undefined; - return { success: true, noteId }; + return { success: true, noteId, noteUrl }; } /** diff --git a/src/platforms/xiaohongshu/routes.ts b/src/platforms/xiaohongshu/routes.ts index 6da8eb5..e00ad1f 100644 --- a/src/platforms/xiaohongshu/routes.ts +++ b/src/platforms/xiaohongshu/routes.ts @@ -5,7 +5,7 @@ import type { BrowserManager } from '../../browser/manager.js'; import { config } from '../../config/index.js'; import { logger } from '../../utils/logger.js'; import { classifyError, sanitizeErrorMessage } from '../../utils/errors.js'; -import { validateMediaPath } from '../../utils/downloader.js'; +import { resolveMediaInput, cleanupFile } from '../../utils/downloader.js'; import { rateLimiter } from '../../server/middleware.js'; import { cookieStore } from '../../cookie/store.js'; @@ -16,6 +16,7 @@ import { getFeedDetail } from './feed-detail.js'; import { getUserProfile } from './user-profile.js'; import { publishImageNote } from './publish.js'; import { publishVideoNote } from './publish-video.js'; +import { listMyNotes } from './my-notes.js'; import { postComment, replyComment } from './comment.js'; import { toggleLike, toggleFavorite } from './interaction.js'; @@ -356,6 +357,34 @@ export function createXhsRoutes(browser: BrowserManager): Router { })(); }); + // ========================================================================= + // My notes + // ========================================================================= + + // ----------------------------------------------------------------------- + // GET /my-notes + // ----------------------------------------------------------------------- + router.get('/my-notes', readRateLimiter, (_req, res) => { + void (async () => { + try { + const timeoutMs = + config.operationTimeouts['feed_list'] ?? + config.operationTimeouts['default'] ?? + 60_000; + + const notes = await browser.withPage( + PLATFORM, + async (page) => listMyNotes(page), + timeoutMs, + ); + + res.json(successResponse(notes) as ApiResponse); + } catch (err) { + handleError(res, err); + } + })(); + }); + // ========================================================================= // Content publishing // ========================================================================= @@ -368,33 +397,36 @@ export function createXhsRoutes(browser: BrowserManager): Router { try { const body = PublishImageBodySchema.parse(req.body); - // Validate all image paths before acquiring a browser page. - const validatedPaths: string[] = []; - for (const imagePath of body.images) { - const resolved = await validateMediaPath(imagePath, { - maxSizeMB: IMAGE_MAX_SIZE_MB, - }); - validatedPaths.push(resolved); + // Resolve all images (local path or URL download) before acquiring browser. + const resolved: Array<{ path: string; temporary: boolean }> = []; + for (const img of body.images) { + resolved.push(await resolveMediaInput(img, { maxSizeMB: IMAGE_MAX_SIZE_MB })); } + const validatedPaths = resolved.map((r) => r.path); const timeoutMs = config.operationTimeouts['publish'] ?? config.operationTimeouts['default'] ?? 300_000; - const result = await browser.withPage( - PLATFORM, - async (page) => - publishImageNote(page, body.title, body.content, validatedPaths, { - tags: body.tags, - scheduleAt: body.schedule_at, - isOriginal: body.is_original, - visibility: body.visibility, - }), - timeoutMs, - ); - - res.json(successResponse(result) as ApiResponse); + try { + const result = await browser.withPage( + PLATFORM, + async (page) => + publishImageNote(page, body.title, body.content, validatedPaths, { + tags: body.tags, + scheduleAt: body.schedule_at, + isOriginal: body.is_original, + visibility: body.visibility, + }), + timeoutMs, + ); + res.json(successResponse(result) as ApiResponse); + } finally { + for (const r of resolved) { + if (r.temporary) await cleanupFile(r.path).catch(() => undefined); + } + } } catch (err) { handleError(res, err); } @@ -409,28 +441,29 @@ export function createXhsRoutes(browser: BrowserManager): Router { try { const body = PublishVideoBodySchema.parse(req.body); - // Validate the video path before acquiring a browser page. - const validatedPath = await validateMediaPath(body.video, { - maxSizeMB: VIDEO_MAX_SIZE_MB, - }); + // Resolve video (local path or URL download) before acquiring browser. + const resolvedVideo = await resolveMediaInput(body.video, { maxSizeMB: VIDEO_MAX_SIZE_MB }); const timeoutMs = config.operationTimeouts['publish'] ?? config.operationTimeouts['default'] ?? 300_000; - const result = await browser.withPage( - PLATFORM, - async (page) => - publishVideoNote(page, body.title, body.content, validatedPath, { - tags: body.tags, - scheduleAt: body.schedule_at, - visibility: body.visibility, - }), - timeoutMs, - ); - - res.json(successResponse(result) as ApiResponse); + try { + const result = await browser.withPage( + PLATFORM, + async (page) => + publishVideoNote(page, body.title, body.content, resolvedVideo.path, { + tags: body.tags, + scheduleAt: body.schedule_at, + visibility: body.visibility, + }), + timeoutMs, + ); + res.json(successResponse(result) as ApiResponse); + } finally { + if (resolvedVideo.temporary) await cleanupFile(resolvedVideo.path).catch(() => undefined); + } } catch (err) { handleError(res, err); } diff --git a/src/platforms/xiaohongshu/schemas.ts b/src/platforms/xiaohongshu/schemas.ts index 7b47594..cc0c4fd 100644 --- a/src/platforms/xiaohongshu/schemas.ts +++ b/src/platforms/xiaohongshu/schemas.ts @@ -67,12 +67,13 @@ export const GetUserProfileSchema = { /** xhs_publish_image */ export const PublishImageSchema = { - title: z.string().min(1).describe('Note title'), - content: z.string().describe('Note body text'), + title: z.string().min(1).max(20, 'Title must be ≤ 20 characters').describe('Note title (max 20 chars)'), + content: z.string().max(1000, 'Content must be ≤ 1000 characters').describe('Note body text (max 1000 chars)'), images: z .array(z.string()) .min(1) - .describe('Array of image file paths or URLs'), + .max(18, 'Maximum 18 images per note') + .describe('Array of local file paths or HTTP/HTTPS URLs (1–18 images)'), tags: z.array(z.string()).optional().describe('Hashtags to attach'), schedule_at: z .string() @@ -92,9 +93,9 @@ export const PublishImageSchema = { /** xhs_publish_video */ export const PublishVideoSchema = { - title: z.string().min(1).describe('Note title'), - content: z.string().describe('Note body text'), - video: z.string().describe('Video file path or URL'), + title: z.string().min(1).max(20, 'Title must be ≤ 20 characters').describe('Note title (max 20 chars)'), + content: z.string().max(1000, 'Content must be ≤ 1000 characters').describe('Note body text (max 1000 chars)'), + video: z.string().describe('Local file path or HTTP/HTTPS URL for the video'), tags: z.array(z.string()).optional().describe('Hashtags to attach'), schedule_at: z .string() @@ -136,6 +137,9 @@ export const LikeSchema = { .describe('Set to true to unlike'), }; +/** xhs_list_my_notes — no parameters. */ +export const ListMyNotesSchema = {}; + /** xhs_favorite */ export const FavoriteSchema = { feed_id: z.string().describe('Feed ID to favorite'), diff --git a/src/utils/downloader.ts b/src/utils/downloader.ts index 51c7c9c..c15f909 100644 --- a/src/utils/downloader.ts +++ b/src/utils/downloader.ts @@ -1,4 +1,5 @@ import { open, stat, unlink, writeFile, mkdir } from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { randomUUID } from "node:crypto"; @@ -225,6 +226,44 @@ export async function downloadFile( // cleanupFile // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// resolveMediaInput +// --------------------------------------------------------------------------- + +/** + * Resolve a media input that is either a local file path or a remote URL. + * + * - If `input` is an HTTP/HTTPS URL, the file is downloaded to a temporary + * directory, validated, and returned with `temporary: true` so the caller + * can clean it up after use. + * - Otherwise the path is validated in-place and returned with `temporary: false`. + * + * @returns `{ path, temporary }` where `path` is the local file path ready for use. + */ +export async function resolveMediaInput( + input: string, + opts?: { maxSizeMB?: number; tempDir?: string }, +): Promise<{ path: string; temporary: boolean }> { + if (input.startsWith("http://") || input.startsWith("https://")) { + const dir = opts?.tempDir ?? path.join(os.tmpdir(), "social-mcp"); + const downloaded = await downloadFile(input, dir); + try { + await validateMediaPath(downloaded, { maxSizeMB: opts?.maxSizeMB }); + } catch (err) { + await cleanupFile(downloaded).catch(() => undefined); + throw err; + } + return { path: downloaded, temporary: true }; + } + + const validated = await validateMediaPath(input, { maxSizeMB: opts?.maxSizeMB }); + return { path: validated, temporary: false }; +} + +// --------------------------------------------------------------------------- +// cleanupFile +// --------------------------------------------------------------------------- + /** * Delete a local file. Silently succeeds if the file does not exist. */ diff --git a/web/src/App.tsx b/web/src/App.tsx index fa6fd52..4e3da63 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { }> } /> } /> - } /> } /> } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 9ed0582..c6fe853 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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}'`); diff --git a/web/src/components/feed/PublishModal.tsx b/web/src/components/feed/PublishModal.tsx new file mode 100644 index 0000000..55ea32d --- /dev/null +++ b/web/src/components/feed/PublishModal.tsx @@ -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('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 ( +
+
+
+ + {/* Header */} +
+

发布笔记

+ +
+ + {/* Tabs */} +
+ {(['image', 'video'] as TabKey[]).map((key) => ( + + ))} +
+ + {/* Form */} +
+ {tab === 'image' ? ( + <> + setImgTitle(e.target.value)} placeholder="笔记标题(必填)" /> +