改进MCP发布体验:URL媒体下载、内容限制校验、发布返回笔记链接、新增查看已发布笔记工具;整合发布入口至小红书页面modal;端口统一改为9527;新增pnpm run restart脚本
This commit is contained in:
+4
-3
@@ -9,10 +9,11 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
"build:web": "cd web && npm run build && mkdir -p ../dist/web && cp -r dist/* ../dist/web/",
|
"build:web": "cd web && pnpm 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: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": "tsup --watch",
|
||||||
"dev:web": "cd web && npm run dev",
|
"dev:web": "cd web && pnpm dev",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
|||||||
+1
-1
@@ -100,7 +100,7 @@ export interface AppConfig {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const config: AppConfig = {
|
export const config: AppConfig = {
|
||||||
port: envInt('PORT', 3000),
|
port: envInt('PORT', 9527),
|
||||||
host,
|
host,
|
||||||
headless: envBool('HEADLESS', true),
|
headless: envBool('HEADLESS', true),
|
||||||
browserBin: process.env['BROWSER_BIN'] || undefined,
|
browserBin: process.env['BROWSER_BIN'] || undefined,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { Router } from 'express';
|
|||||||
import type { BrowserManager } from '../../browser/manager.js';
|
import type { BrowserManager } from '../../browser/manager.js';
|
||||||
import { config } from '../../config/index.js';
|
import { config } from '../../config/index.js';
|
||||||
import { withErrorHandling } from '../../utils/errors.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 { checkLoginStatus, getLoginQRCode, deleteCookies } from './login.js';
|
||||||
import { listFeeds } from './feeds.js';
|
import { listFeeds } from './feeds.js';
|
||||||
import { searchFeeds } from './search.js';
|
import { searchFeeds } from './search.js';
|
||||||
@@ -12,6 +12,7 @@ import { getFeedDetail } from './feed-detail.js';
|
|||||||
import { getUserProfile } from './user-profile.js';
|
import { getUserProfile } from './user-profile.js';
|
||||||
import { publishImageNote } from './publish.js';
|
import { publishImageNote } from './publish.js';
|
||||||
import { publishVideoNote } from './publish-video.js';
|
import { publishVideoNote } from './publish-video.js';
|
||||||
|
import { listMyNotes } from './my-notes.js';
|
||||||
import { postComment, replyComment } from './comment.js';
|
import { postComment, replyComment } from './comment.js';
|
||||||
import { toggleLike, toggleFavorite } from './interaction.js';
|
import { toggleLike, toggleFavorite } from './interaction.js';
|
||||||
import { createXhsRoutes } from './routes.js';
|
import { createXhsRoutes } from './routes.js';
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
GetUserProfileSchema,
|
GetUserProfileSchema,
|
||||||
PublishImageSchema,
|
PublishImageSchema,
|
||||||
PublishVideoSchema,
|
PublishVideoSchema,
|
||||||
|
ListMyNotesSchema,
|
||||||
PostCommentSchema,
|
PostCommentSchema,
|
||||||
ReplyCommentSchema,
|
ReplyCommentSchema,
|
||||||
LikeSchema,
|
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)
|
// Phase 4: Content publishing (2 tools)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -301,40 +335,39 @@ export const xiaohongshuPlugin: PlatformPlugin = {
|
|||||||
PublishImageSchema,
|
PublishImageSchema,
|
||||||
async (args) => {
|
async (args) => {
|
||||||
return withErrorHandling('xhs_publish_image', async () => {
|
return withErrorHandling('xhs_publish_image', async () => {
|
||||||
// Fail fast: validate all image paths BEFORE acquiring a browser page.
|
// Resolve all images (local path or URL download) before acquiring browser.
|
||||||
const validatedPaths: string[] = [];
|
const resolved: Array<{ path: string; temporary: boolean }> = [];
|
||||||
for (const imagePath of args.images) {
|
for (const img of args.images) {
|
||||||
const resolved = await validateMediaPath(imagePath, {
|
resolved.push(await resolveMediaInput(img, { maxSizeMB: IMAGE_MAX_SIZE_MB }));
|
||||||
maxSizeMB: IMAGE_MAX_SIZE_MB,
|
|
||||||
});
|
|
||||||
validatedPaths.push(resolved);
|
|
||||||
}
|
}
|
||||||
|
const validatedPaths = resolved.map((r) => r.path);
|
||||||
|
|
||||||
const timeoutMs =
|
const timeoutMs =
|
||||||
config.operationTimeouts['publish'] ??
|
config.operationTimeouts['publish'] ??
|
||||||
config.operationTimeouts['default'] ??
|
config.operationTimeouts['default'] ??
|
||||||
300_000;
|
300_000;
|
||||||
|
|
||||||
const result = await browser.withPage(
|
try {
|
||||||
PLATFORM,
|
const result = await browser.withPage(
|
||||||
async (page) =>
|
PLATFORM,
|
||||||
publishImageNote(page, args.title, args.content, validatedPaths, {
|
async (page) =>
|
||||||
tags: args.tags,
|
publishImageNote(page, args.title, args.content, validatedPaths, {
|
||||||
scheduleAt: args.schedule_at,
|
tags: args.tags,
|
||||||
isOriginal: args.is_original,
|
scheduleAt: args.schedule_at,
|
||||||
visibility: args.visibility,
|
isOriginal: args.is_original,
|
||||||
}),
|
visibility: args.visibility,
|
||||||
timeoutMs,
|
}),
|
||||||
);
|
timeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
|
||||||
{
|
};
|
||||||
type: 'text' as const,
|
} finally {
|
||||||
text: JSON.stringify(result),
|
for (const r of resolved) {
|
||||||
},
|
if (r.temporary) await cleanupFile(r.path).catch(() => undefined);
|
||||||
],
|
}
|
||||||
};
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -349,35 +382,32 @@ export const xiaohongshuPlugin: PlatformPlugin = {
|
|||||||
PublishVideoSchema,
|
PublishVideoSchema,
|
||||||
async (args) => {
|
async (args) => {
|
||||||
return withErrorHandling('xhs_publish_video', async () => {
|
return withErrorHandling('xhs_publish_video', async () => {
|
||||||
// Fail fast: validate the video path BEFORE acquiring a browser page.
|
// Resolve video (local path or URL download) before acquiring browser.
|
||||||
const validatedPath = await validateMediaPath(args.video, {
|
const resolvedVideo = await resolveMediaInput(args.video, { maxSizeMB: VIDEO_MAX_SIZE_MB });
|
||||||
maxSizeMB: VIDEO_MAX_SIZE_MB,
|
|
||||||
});
|
|
||||||
|
|
||||||
const timeoutMs =
|
const timeoutMs =
|
||||||
config.operationTimeouts['publish'] ??
|
config.operationTimeouts['publish'] ??
|
||||||
config.operationTimeouts['default'] ??
|
config.operationTimeouts['default'] ??
|
||||||
300_000;
|
300_000;
|
||||||
|
|
||||||
const result = await browser.withPage(
|
try {
|
||||||
PLATFORM,
|
const result = await browser.withPage(
|
||||||
async (page) =>
|
PLATFORM,
|
||||||
publishVideoNote(page, args.title, args.content, validatedPath, {
|
async (page) =>
|
||||||
tags: args.tags,
|
publishVideoNote(page, args.title, args.content, resolvedVideo.path, {
|
||||||
scheduleAt: args.schedule_at,
|
tags: args.tags,
|
||||||
visibility: args.visibility,
|
scheduleAt: args.schedule_at,
|
||||||
}),
|
visibility: args.visibility,
|
||||||
timeoutMs,
|
}),
|
||||||
);
|
timeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
|
||||||
{
|
};
|
||||||
type: 'text' as const,
|
} finally {
|
||||||
text: JSON.stringify(result),
|
if (resolvedVideo.temporary) await cleanupFile(resolvedVideo.path).catch(() => undefined);
|
||||||
},
|
}
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<MyNote[]> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ export async function publishVideoNote(
|
|||||||
content: string,
|
content: string,
|
||||||
videoPath: string,
|
videoPath: string,
|
||||||
options?: PublishVideoOptions,
|
options?: PublishVideoOptions,
|
||||||
): Promise<{ success: boolean; noteId?: string }> {
|
): Promise<{ success: boolean; noteId?: string; noteUrl?: string }> {
|
||||||
log.info(
|
log.info(
|
||||||
{ hasOptions: !!options },
|
{ hasOptions: !!options },
|
||||||
'Starting video note publish',
|
'Starting video note publish',
|
||||||
@@ -260,7 +260,7 @@ async function setSchedule(page: Page, scheduleAt: string): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
async function waitForPublishResult(
|
async function waitForPublishResult(
|
||||||
page: Page,
|
page: Page,
|
||||||
): Promise<{ success: boolean; noteId?: string }> {
|
): Promise<{ success: boolean; noteId?: string; noteUrl?: string }> {
|
||||||
const urlChangePromise = page
|
const urlChangePromise = page
|
||||||
.waitForURL(sel.publishSuccessUrlPattern, { timeout: 30_000 })
|
.waitForURL(sel.publishSuccessUrlPattern, { timeout: 30_000 })
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
@@ -290,8 +290,9 @@ async function waitForPublishResult(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const noteId = extractNoteIdFromUrl(page.url());
|
const noteId = extractNoteIdFromUrl(page.url());
|
||||||
|
const noteUrl = noteId ? `https://www.xiaohongshu.com/explore/${noteId}` : undefined;
|
||||||
|
|
||||||
return { success: true, noteId };
|
return { success: true, noteId, noteUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export async function publishImageNote(
|
|||||||
content: string,
|
content: string,
|
||||||
imagePaths: string[],
|
imagePaths: string[],
|
||||||
options?: PublishImageOptions,
|
options?: PublishImageOptions,
|
||||||
): Promise<{ success: boolean; noteId?: string }> {
|
): Promise<{ success: boolean; noteId?: string; noteUrl?: string }> {
|
||||||
log.info(
|
log.info(
|
||||||
{ imageCount: imagePaths.length, hasOptions: !!options },
|
{ imageCount: imagePaths.length, hasOptions: !!options },
|
||||||
'Starting image note publish',
|
'Starting image note publish',
|
||||||
@@ -354,7 +354,7 @@ async function setSchedule(page: Page, scheduleAt: string): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
async function waitForPublishResult(
|
async function waitForPublishResult(
|
||||||
page: Page,
|
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 1: Wait for the URL to change to a success page.
|
||||||
// Strategy 2: Wait for a success element to appear.
|
// Strategy 2: Wait for a success element to appear.
|
||||||
// Use Promise.all so both run concurrently.
|
// 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.
|
// Try to extract the note ID from the current URL if available.
|
||||||
const noteId = extractNoteIdFromUrl(page.url());
|
const noteId = extractNoteIdFromUrl(page.url());
|
||||||
|
const noteUrl = noteId ? `https://www.xiaohongshu.com/explore/${noteId}` : undefined;
|
||||||
|
|
||||||
return { success: true, noteId };
|
return { success: true, noteId, noteUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { BrowserManager } from '../../browser/manager.js';
|
|||||||
import { config } from '../../config/index.js';
|
import { config } from '../../config/index.js';
|
||||||
import { logger } from '../../utils/logger.js';
|
import { logger } from '../../utils/logger.js';
|
||||||
import { classifyError, sanitizeErrorMessage } from '../../utils/errors.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 { rateLimiter } from '../../server/middleware.js';
|
||||||
import { cookieStore } from '../../cookie/store.js';
|
import { cookieStore } from '../../cookie/store.js';
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ import { getFeedDetail } from './feed-detail.js';
|
|||||||
import { getUserProfile } from './user-profile.js';
|
import { getUserProfile } from './user-profile.js';
|
||||||
import { publishImageNote } from './publish.js';
|
import { publishImageNote } from './publish.js';
|
||||||
import { publishVideoNote } from './publish-video.js';
|
import { publishVideoNote } from './publish-video.js';
|
||||||
|
import { listMyNotes } from './my-notes.js';
|
||||||
import { postComment, replyComment } from './comment.js';
|
import { postComment, replyComment } from './comment.js';
|
||||||
import { toggleLike, toggleFavorite } from './interaction.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<typeof notes>);
|
||||||
|
} catch (err) {
|
||||||
|
handleError(res, err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Content publishing
|
// Content publishing
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -368,33 +397,36 @@ export function createXhsRoutes(browser: BrowserManager): Router {
|
|||||||
try {
|
try {
|
||||||
const body = PublishImageBodySchema.parse(req.body);
|
const body = PublishImageBodySchema.parse(req.body);
|
||||||
|
|
||||||
// Validate all image paths before acquiring a browser page.
|
// Resolve all images (local path or URL download) before acquiring browser.
|
||||||
const validatedPaths: string[] = [];
|
const resolved: Array<{ path: string; temporary: boolean }> = [];
|
||||||
for (const imagePath of body.images) {
|
for (const img of body.images) {
|
||||||
const resolved = await validateMediaPath(imagePath, {
|
resolved.push(await resolveMediaInput(img, { maxSizeMB: IMAGE_MAX_SIZE_MB }));
|
||||||
maxSizeMB: IMAGE_MAX_SIZE_MB,
|
|
||||||
});
|
|
||||||
validatedPaths.push(resolved);
|
|
||||||
}
|
}
|
||||||
|
const validatedPaths = resolved.map((r) => r.path);
|
||||||
|
|
||||||
const timeoutMs =
|
const timeoutMs =
|
||||||
config.operationTimeouts['publish'] ??
|
config.operationTimeouts['publish'] ??
|
||||||
config.operationTimeouts['default'] ??
|
config.operationTimeouts['default'] ??
|
||||||
300_000;
|
300_000;
|
||||||
|
|
||||||
const result = await browser.withPage(
|
try {
|
||||||
PLATFORM,
|
const result = await browser.withPage(
|
||||||
async (page) =>
|
PLATFORM,
|
||||||
publishImageNote(page, body.title, body.content, validatedPaths, {
|
async (page) =>
|
||||||
tags: body.tags,
|
publishImageNote(page, body.title, body.content, validatedPaths, {
|
||||||
scheduleAt: body.schedule_at,
|
tags: body.tags,
|
||||||
isOriginal: body.is_original,
|
scheduleAt: body.schedule_at,
|
||||||
visibility: body.visibility,
|
isOriginal: body.is_original,
|
||||||
}),
|
visibility: body.visibility,
|
||||||
timeoutMs,
|
}),
|
||||||
);
|
timeoutMs,
|
||||||
|
);
|
||||||
res.json(successResponse(result) as ApiResponse<typeof result>);
|
res.json(successResponse(result) as ApiResponse<typeof result>);
|
||||||
|
} finally {
|
||||||
|
for (const r of resolved) {
|
||||||
|
if (r.temporary) await cleanupFile(r.path).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(res, err);
|
handleError(res, err);
|
||||||
}
|
}
|
||||||
@@ -409,28 +441,29 @@ export function createXhsRoutes(browser: BrowserManager): Router {
|
|||||||
try {
|
try {
|
||||||
const body = PublishVideoBodySchema.parse(req.body);
|
const body = PublishVideoBodySchema.parse(req.body);
|
||||||
|
|
||||||
// Validate the video path before acquiring a browser page.
|
// Resolve video (local path or URL download) before acquiring browser.
|
||||||
const validatedPath = await validateMediaPath(body.video, {
|
const resolvedVideo = await resolveMediaInput(body.video, { maxSizeMB: VIDEO_MAX_SIZE_MB });
|
||||||
maxSizeMB: VIDEO_MAX_SIZE_MB,
|
|
||||||
});
|
|
||||||
|
|
||||||
const timeoutMs =
|
const timeoutMs =
|
||||||
config.operationTimeouts['publish'] ??
|
config.operationTimeouts['publish'] ??
|
||||||
config.operationTimeouts['default'] ??
|
config.operationTimeouts['default'] ??
|
||||||
300_000;
|
300_000;
|
||||||
|
|
||||||
const result = await browser.withPage(
|
try {
|
||||||
PLATFORM,
|
const result = await browser.withPage(
|
||||||
async (page) =>
|
PLATFORM,
|
||||||
publishVideoNote(page, body.title, body.content, validatedPath, {
|
async (page) =>
|
||||||
tags: body.tags,
|
publishVideoNote(page, body.title, body.content, resolvedVideo.path, {
|
||||||
scheduleAt: body.schedule_at,
|
tags: body.tags,
|
||||||
visibility: body.visibility,
|
scheduleAt: body.schedule_at,
|
||||||
}),
|
visibility: body.visibility,
|
||||||
timeoutMs,
|
}),
|
||||||
);
|
timeoutMs,
|
||||||
|
);
|
||||||
res.json(successResponse(result) as ApiResponse<typeof result>);
|
res.json(successResponse(result) as ApiResponse<typeof result>);
|
||||||
|
} finally {
|
||||||
|
if (resolvedVideo.temporary) await cleanupFile(resolvedVideo.path).catch(() => undefined);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(res, err);
|
handleError(res, err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,12 +67,13 @@ export const GetUserProfileSchema = {
|
|||||||
|
|
||||||
/** xhs_publish_image */
|
/** xhs_publish_image */
|
||||||
export const PublishImageSchema = {
|
export const PublishImageSchema = {
|
||||||
title: z.string().min(1).describe('Note title'),
|
title: z.string().min(1).max(20, 'Title must be ≤ 20 characters').describe('Note title (max 20 chars)'),
|
||||||
content: z.string().describe('Note body text'),
|
content: z.string().max(1000, 'Content must be ≤ 1000 characters').describe('Note body text (max 1000 chars)'),
|
||||||
images: z
|
images: z
|
||||||
.array(z.string())
|
.array(z.string())
|
||||||
.min(1)
|
.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'),
|
tags: z.array(z.string()).optional().describe('Hashtags to attach'),
|
||||||
schedule_at: z
|
schedule_at: z
|
||||||
.string()
|
.string()
|
||||||
@@ -92,9 +93,9 @@ export const PublishImageSchema = {
|
|||||||
|
|
||||||
/** xhs_publish_video */
|
/** xhs_publish_video */
|
||||||
export const PublishVideoSchema = {
|
export const PublishVideoSchema = {
|
||||||
title: z.string().min(1).describe('Note title'),
|
title: z.string().min(1).max(20, 'Title must be ≤ 20 characters').describe('Note title (max 20 chars)'),
|
||||||
content: z.string().describe('Note body text'),
|
content: z.string().max(1000, 'Content must be ≤ 1000 characters').describe('Note body text (max 1000 chars)'),
|
||||||
video: z.string().describe('Video file path or URL'),
|
video: z.string().describe('Local file path or HTTP/HTTPS URL for the video'),
|
||||||
tags: z.array(z.string()).optional().describe('Hashtags to attach'),
|
tags: z.array(z.string()).optional().describe('Hashtags to attach'),
|
||||||
schedule_at: z
|
schedule_at: z
|
||||||
.string()
|
.string()
|
||||||
@@ -136,6 +137,9 @@ export const LikeSchema = {
|
|||||||
.describe('Set to true to unlike'),
|
.describe('Set to true to unlike'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** xhs_list_my_notes — no parameters. */
|
||||||
|
export const ListMyNotesSchema = {};
|
||||||
|
|
||||||
/** xhs_favorite */
|
/** xhs_favorite */
|
||||||
export const FavoriteSchema = {
|
export const FavoriteSchema = {
|
||||||
feed_id: z.string().describe('Feed ID to favorite'),
|
feed_id: z.string().describe('Feed ID to favorite'),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { open, stat, unlink, writeFile, mkdir } from "node:fs/promises";
|
import { open, stat, unlink, writeFile, mkdir } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
@@ -225,6 +226,44 @@ export async function downloadFile(
|
|||||||
// cleanupFile
|
// 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.
|
* Delete a local file. Silently succeeds if the file does not exist.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { ToastProvider } from '@/context/ToastContext';
|
|||||||
import { Layout } from '@/components/layout/Layout';
|
import { Layout } from '@/components/layout/Layout';
|
||||||
import { DashboardPage } from '@/pages/DashboardPage';
|
import { DashboardPage } from '@/pages/DashboardPage';
|
||||||
import { XiaohongshuPage } from '@/pages/XiaohongshuPage';
|
import { XiaohongshuPage } from '@/pages/XiaohongshuPage';
|
||||||
import { PublishPage } from '@/pages/PublishPage';
|
|
||||||
import { InteractionsPage } from '@/pages/InteractionsPage';
|
import { InteractionsPage } from '@/pages/InteractionsPage';
|
||||||
import { ApiTesterPage } from '@/pages/ApiTesterPage';
|
import { ApiTesterPage } from '@/pages/ApiTesterPage';
|
||||||
import { SettingsPage } from '@/pages/SettingsPage';
|
import { SettingsPage } from '@/pages/SettingsPage';
|
||||||
@@ -18,7 +17,6 @@ export default function App() {
|
|||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
<Route path="xhs" element={<XiaohongshuPage />} />
|
<Route path="xhs" element={<XiaohongshuPage />} />
|
||||||
<Route path="publish" element={<PublishPage />} />
|
|
||||||
<Route path="interactions" element={<InteractionsPage />} />
|
<Route path="interactions" element={<InteractionsPage />} />
|
||||||
<Route path="api-tester" element={<ApiTesterPage />} />
|
<Route path="api-tester" element={<ApiTesterPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function generateCurl(
|
|||||||
path: string,
|
path: string,
|
||||||
body?: unknown,
|
body?: unknown,
|
||||||
): string {
|
): string {
|
||||||
const baseUrl = getBaseUrl() || 'http://127.0.0.1:3000';
|
const baseUrl = getBaseUrl() || 'http://127.0.0.1:9527';
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const parts = [`curl -X ${method}`];
|
const parts = [`curl -X ${method}`];
|
||||||
parts.push(`'${baseUrl}${path}'`);
|
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 = [
|
export const NAV_ITEMS = [
|
||||||
{ path: '/', label: '仪表盘', icon: 'dashboard' },
|
{ path: '/', label: '仪表盘', icon: 'dashboard' },
|
||||||
{ path: '/xhs', label: '小红书', icon: 'xhs' },
|
{ path: '/xhs', label: '小红书', icon: 'xhs' },
|
||||||
{ path: '/publish', label: '发布', icon: 'publish' },
|
|
||||||
{ path: '/interactions', label: '互动', icon: 'interactions' },
|
{ path: '/interactions', label: '互动', icon: 'interactions' },
|
||||||
{ path: '/api-tester', label: 'API 测试', icon: 'api' },
|
{ path: '/api-tester', label: 'API 测试', icon: 'api' },
|
||||||
{ path: '/settings', label: '设置', icon: 'settings' },
|
{ 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">
|
<p className="text-xs text-dark-muted">
|
||||||
当 Dashboard 由同一个 Express 服务器提供时留空。设置为例如{' '}
|
当 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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Spinner } from '@/components/ui/Spinner';
|
|||||||
import { FeedGrid } from '@/components/feed/FeedGrid';
|
import { FeedGrid } from '@/components/feed/FeedGrid';
|
||||||
import { FeedDetail } from '@/components/feed/FeedDetail';
|
import { FeedDetail } from '@/components/feed/FeedDetail';
|
||||||
import { UserCard } from '@/components/feed/UserCard';
|
import { UserCard } from '@/components/feed/UserCard';
|
||||||
|
import { PublishModal } from '@/components/feed/PublishModal';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { useToast } from '@/context/ToastContext';
|
import { useToast } from '@/context/ToastContext';
|
||||||
import { useLoginStatus } from '@/hooks/useLoginStatus';
|
import { useLoginStatus } from '@/hooks/useLoginStatus';
|
||||||
@@ -134,6 +135,7 @@ export function XiaohongshuPage() {
|
|||||||
const [searchLoading, setSearchLoading] = useState(false);
|
const [searchLoading, setSearchLoading] = useState(false);
|
||||||
const [selectedFeed, setSelectedFeed] = useState<{ id: string; xsecToken: string } | null>(null);
|
const [selectedFeed, setSelectedFeed] = useState<{ id: string; xsecToken: string } | null>(null);
|
||||||
const [userView, setUserView] = useState<{ userId: string; xsecToken: string } | null>(null);
|
const [userView, setUserView] = useState<{ userId: string; xsecToken: string } | null>(null);
|
||||||
|
const [publishOpen, setPublishOpen] = useState(false);
|
||||||
|
|
||||||
const loadFeed = useCallback(async () => {
|
const loadFeed = useCallback(async () => {
|
||||||
setFeedsLoading(true);
|
setFeedsLoading(true);
|
||||||
@@ -226,6 +228,9 @@ export function XiaohongshuPage() {
|
|||||||
清除
|
清除
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setPublishOpen(true)}>
|
||||||
|
发布
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -351,6 +356,9 @@ export function XiaohongshuPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Publish modal ── */}
|
||||||
|
{publishOpen && <PublishModal onClose={() => setPublishOpen(false)} />}
|
||||||
|
|
||||||
{/* ── User profile slide-over ── */}
|
{/* ── User profile slide-over ── */}
|
||||||
{userView && (
|
{userView && (
|
||||||
<div className="fixed inset-0 z-50 flex">
|
<div className="fixed inset-0 z-50 flex">
|
||||||
|
|||||||
+2
-2
@@ -13,11 +13,11 @@ export default defineConfig({
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://127.0.0.1:3000',
|
target: 'http://127.0.0.1:9527',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/health': {
|
'/health': {
|
||||||
target: 'http://127.0.0.1:3000',
|
target: 'http://127.0.0.1:9527',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user