feat: 添加 Admin Dashboard — React 19 SPA,包含 7 个页面
- Dashboard: 健康状态轮询、状态卡片、内存统计、快捷操作 - Login: 二维码展示 + 3 秒自动轮询 + 倒计时 + 登出 - Browser: 探索/搜索/用户三标签页,Feed 网格、详情面板、评论树 - Publish: 图文/视频发布表单,支持标签、可见性、定时发布 - Interactions: 点赞/取消点赞、收藏、评论、回复 + 操作日志 - API Tester: 端点选择器、请求体编辑器、cURL 生成、响应查看、历史记录 - Settings: Token 配置、服务器 URL 设置 后端改动: - app.ts: 生产环境提供 dist/web/ 静态文件服务 + SPA fallback - Dockerfile: 添加 web 构建阶段 - package.json: 添加 build:web、build:all、dev:web 脚本 技术栈: React 19 + TypeScript + Vite 6 + Tailwind CSS(暗色主题) 产物: 85.5 KB gzip JS + 4 KB gzip CSS,零重型依赖
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
const getBaseUrl = (): string => {
|
||||
return localStorage.getItem('smcp_server_url') || '';
|
||||
};
|
||||
|
||||
const getToken = (): string | null => {
|
||||
return localStorage.getItem('smcp_token');
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const baseUrl = getBaseUrl();
|
||||
const token = getToken();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...((options.headers as Record<string, string>) || {}),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let code = 'UNKNOWN';
|
||||
let message = `HTTP ${res.status}`;
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body.error) {
|
||||
code = body.error.code || code;
|
||||
message = body.error.message || message;
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
throw new ApiError(res.status, code, message);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function generateCurl(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): string {
|
||||
const baseUrl = getBaseUrl() || 'http://127.0.0.1:3000';
|
||||
const token = getToken();
|
||||
const parts = [`curl -X ${method}`];
|
||||
parts.push(`'${baseUrl}${path}'`);
|
||||
if (token) {
|
||||
parts.push(`-H 'Authorization: Bearer ${token}'`);
|
||||
}
|
||||
parts.push(`-H 'Content-Type: application/json'`);
|
||||
if (body && method !== 'GET') {
|
||||
parts.push(`-d '${JSON.stringify(body)}'`);
|
||||
}
|
||||
return parts.join(' \\\n ');
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { apiFetch } from './client';
|
||||
import type {
|
||||
LoginStatus,
|
||||
QRCodeResult,
|
||||
Feed,
|
||||
FeedDetail,
|
||||
UserProfile,
|
||||
SearchFilters,
|
||||
HealthResponse,
|
||||
ApiResponse,
|
||||
PublishResult,
|
||||
InteractionResult,
|
||||
CommentResult,
|
||||
} from './types';
|
||||
|
||||
// Health (no auth required)
|
||||
export const getHealth = () =>
|
||||
apiFetch<HealthResponse>('/health');
|
||||
|
||||
// Login
|
||||
export const getLoginStatus = () =>
|
||||
apiFetch<ApiResponse<LoginStatus>>('/api/xhs/login/status');
|
||||
|
||||
export const getLoginQRCode = () =>
|
||||
apiFetch<ApiResponse<QRCodeResult>>('/api/xhs/login/qrcode');
|
||||
|
||||
export const deleteCookies = () =>
|
||||
apiFetch<ApiResponse<{ message: string }>>('/api/xhs/login/cookies', { method: 'DELETE' });
|
||||
|
||||
// Feeds
|
||||
export const listFeeds = () =>
|
||||
apiFetch<ApiResponse<Feed[]>>('/api/xhs/feeds');
|
||||
|
||||
export const searchFeeds = (keyword: string, filters?: SearchFilters) =>
|
||||
apiFetch<ApiResponse<Feed[]>>('/api/xhs/search', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ keyword, filters }),
|
||||
});
|
||||
|
||||
export const getFeedDetail = (feedId: string, xsecToken: string, loadAllComments = false) =>
|
||||
apiFetch<ApiResponse<FeedDetail>>('/api/xhs/feeds/detail', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken, load_all_comments: loadAllComments }),
|
||||
});
|
||||
|
||||
// User
|
||||
export const getUserProfile = (userId: string, xsecToken: string) =>
|
||||
apiFetch<ApiResponse<UserProfile>>('/api/xhs/user/profile', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ user_id: userId, xsec_token: xsecToken }),
|
||||
});
|
||||
|
||||
// Publish
|
||||
export const publishImage = (data: {
|
||||
title: string;
|
||||
content: string;
|
||||
images: string[];
|
||||
tags?: string[];
|
||||
schedule_at?: string;
|
||||
is_original?: boolean;
|
||||
visibility?: 'public' | 'private' | 'friends';
|
||||
}) =>
|
||||
apiFetch<ApiResponse<PublishResult>>('/api/xhs/publish/image', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
export const publishVideo = (data: {
|
||||
title: string;
|
||||
content: string;
|
||||
video: string;
|
||||
tags?: string[];
|
||||
schedule_at?: string;
|
||||
visibility?: 'public' | 'private' | 'friends';
|
||||
}) =>
|
||||
apiFetch<ApiResponse<PublishResult>>('/api/xhs/publish/video', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
// Interactions
|
||||
export const postComment = (feedId: string, xsecToken: string, content: string) =>
|
||||
apiFetch<ApiResponse<CommentResult>>('/api/xhs/comment', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken, content }),
|
||||
});
|
||||
|
||||
export const replyComment = (data: {
|
||||
feed_id: string;
|
||||
xsec_token: string;
|
||||
content: string;
|
||||
comment_id?: string;
|
||||
user_id?: string;
|
||||
}) =>
|
||||
apiFetch<ApiResponse<CommentResult>>('/api/xhs/comment/reply', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
export const toggleLike = (feedId: string, xsecToken: string, unlike = false) =>
|
||||
apiFetch<ApiResponse<InteractionResult>>('/api/xhs/like', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken, unlike }),
|
||||
});
|
||||
|
||||
export const toggleFavorite = (feedId: string, xsecToken: string, unfavorite = false) =>
|
||||
apiFetch<ApiResponse<InteractionResult>>('/api/xhs/favorite', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken, unfavorite }),
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
// Mirror of backend types from src/platforms/xiaohongshu/types.ts
|
||||
|
||||
export interface LoginStatus {
|
||||
loggedIn: boolean;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface QRCodeResult {
|
||||
qrcodeData: string;
|
||||
alreadyLoggedIn: boolean;
|
||||
timeout: string;
|
||||
}
|
||||
|
||||
export interface FeedUser {
|
||||
id: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
export interface Feed {
|
||||
id: string;
|
||||
xsecToken: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: 'normal' | 'video';
|
||||
coverUrl: string;
|
||||
likeCount: number;
|
||||
user: FeedUser;
|
||||
}
|
||||
|
||||
export interface FeedDetail {
|
||||
id: string;
|
||||
xsecToken: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: 'normal' | 'video';
|
||||
images: string[];
|
||||
videoUrl?: string;
|
||||
tags: string[];
|
||||
likeCount: number;
|
||||
collectCount: number;
|
||||
commentCount: number;
|
||||
shareCount: number;
|
||||
createTime: string;
|
||||
lastUpdateTime: string;
|
||||
ipLocation: string;
|
||||
user: FeedUser;
|
||||
comments: Comment[];
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
userId: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
content: string;
|
||||
likeCount: number;
|
||||
createTime: string;
|
||||
ipLocation: string;
|
||||
subComments: Comment[];
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
description: string;
|
||||
gender: string;
|
||||
ipLocation: string;
|
||||
follows: number;
|
||||
fans: number;
|
||||
interaction: number;
|
||||
feedCount: number;
|
||||
feeds: Feed[];
|
||||
}
|
||||
|
||||
export interface SearchFilters {
|
||||
sort?: 'general' | 'time_descending' | 'popularity_descending';
|
||||
type?: 'all' | 'note' | 'video';
|
||||
time?: 'all' | 'day' | 'week' | 'half_year';
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
healthy: boolean;
|
||||
version: string;
|
||||
uptime: number;
|
||||
shuttingDown: boolean;
|
||||
activeSessions: number;
|
||||
plugins: Record<string, { healthy: boolean; message?: string }>;
|
||||
memory: {
|
||||
rss: number;
|
||||
heapUsed: number;
|
||||
heapTotal: number;
|
||||
external: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: { code: string; message: string };
|
||||
}
|
||||
|
||||
export interface PublishResult {
|
||||
success: boolean;
|
||||
noteId?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface InteractionResult {
|
||||
success: boolean;
|
||||
action: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CommentResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user