Files
chunyu_prject_react/src/pages/ApiDirectory/ApiDirectoryDesktop.tsx
T
chunyu 24f200bfa6 feat(C-01):路由/代理前缀冲突修复
SPA冲突路由改名+旧路径Navigate重定向(/api-docs→/open-api-docs、/api-detail→/open-api-detail、/article→/post、/user→/user-home);vite代理宽前缀改精确正则白名单;nginx.conf同步同口径;新增86路由回归脚本+Playwright 3项(86/86全绿)。
2026-09-15 15:25:59 +08:00

400 lines
12 KiB
TypeScript

import "./ApiDirectoryDesktop.css";
import { Pagination, Tag, Empty, Spin, Button } from "antd";
import { Tabs } from "antd";
import type { TabsProps } from "antd";
import React, { useState, useEffect, useMemo } from "react";
import { Radio } from "antd";
import type { RadioChangeEvent } from "antd";
import { Image } from "antd";
import { EyeOutlined, StarOutlined, StarFilled, LoadingOutlined, ApiOutlined, LinkOutlined, ReloadOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { api_request } from "@/utils/request";
import { getApiImageUrl } from "@/utils/apiImageUtils";
import { useSelector } from "react-redux";
import { selectIsLogin } from "@/features/login";
import { useLoginModal } from "@/contexts/LoginModalContext";
import { message } from "@/utils/message";
interface ApiCategory {
id: number;
name: string;
icon: string;
sort_order: number;
item_count: number;
}
interface ApiItem {
id: number;
name: string;
description: string;
icon: string;
url_path: string;
method: string;
category: number | null;
category_name: string;
is_enabled: boolean;
is_new: boolean;
is_coming_soon: boolean;
views_count: number;
color: string;
image_url: string;
sort_order: number;
favorites_count: number;
is_favorited: boolean;
[key: string]: any;
}
const methodColors: Record<string, string> = {
GET: "green",
POST: "orange",
PUT: "blue",
DELETE: "red",
};
const ApiDirectoryDesktop: React.FC = () => {
const navigate = useNavigate();
const isLogin = useSelector(selectIsLogin);
const { openLoginModal } = useLoginModal();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [categories, setCategories] = useState<ApiCategory[]>([]);
const [items, setItems] = useState<ApiItem[]>([]);
const [activeCategory, setActiveCategory] = useState<string>("all");
const [sortOrder, setSortOrder] = useState<number>(1);
const [currentPage, setCurrentPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(12);
const [favoriteLoading, setFavoriteLoading] = useState<Record<number, boolean>>({});
const handleApiClick = async (api: ApiItem) => {
if (api.is_coming_soon) return;
if (isLogin) {
try {
await api_request.history.create({
type: 'api',
title: api.name,
description: api.description,
image: getApiImageUrl(api),
category: api.category_name || 'API接口',
link: `/open-api-detail/${api.id}`,
});
} catch (err) {
console.error('记录浏览历史失败:', err);
}
}
navigate(`/open-api-detail/${api.id}`);
};
const handleFavoriteToggle = async (apiId: number, e: React.MouseEvent) => {
e.stopPropagation();
if (!isLogin) {
openLoginModal();
return;
}
if (favoriteLoading[apiId]) return;
setFavoriteLoading(prev => ({ ...prev, [apiId]: true }));
try {
const res: any = await api_request.apidirectory.toggleFavorite(apiId);
if (res?.data) {
setItems(prev => prev.map(item =>
item.id === apiId
? {
...item,
is_favorited: res.data.favorited,
favorites_count: res.data.favorites_count ?? (item.is_favorited ? item.favorites_count - 1 : item.favorites_count + 1),
}
: item
));
}
} catch {
message.error("收藏操作失败");
} finally {
setFavoriteLoading(prev => ({ ...prev, [apiId]: false }));
}
};
const fetchCategories = async () => {
try {
const res: any = await api_request.apidirectory.getCategories();
if (res && res.data) {
setCategories(res.data);
}
} catch (err: any) {
console.error('获取分类失败:', err);
}
};
const getOrderingValue = (order: number): string => {
const orderingMap: Record<number, string> = {
1: 'sort_order',
2: '-views_count',
3: '-created_at',
4: '-favorites_count',
};
return orderingMap[order] || 'sort_order';
};
const fetchItems = async (categoryId?: number, order?: number) => {
setLoading(true);
setError(null);
setCurrentPage(1);
try {
const params: any = {};
if (categoryId) {
params.category_id = categoryId;
}
const ordering = getOrderingValue(order ?? sortOrder);
if (ordering) {
params.ordering = ordering;
}
const res: any = await api_request.apidirectory.getItems(params);
if (res && res.data) {
const sorted = [...res.data].sort((a: ApiItem, b: ApiItem) => {
if (a.is_coming_soon && !b.is_coming_soon) return 1;
if (!a.is_coming_soon && b.is_coming_soon) return -1;
return 0;
});
setItems(sorted);
}
} catch (err: any) {
setError('获取API列表失败,请稍后重试');
console.error('获取API列表失败:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchCategories();
fetchItems();
}, []);
const handleCategoryChange = (key: string) => {
setActiveCategory(key);
setCurrentPage(1);
if (key === 'all') {
fetchItems(undefined, sortOrder);
} else {
const category = categories.find(c => c.name === key);
if (category) {
fetchItems(category.id, sortOrder);
}
}
};
const handleSortChange = (e: RadioChangeEvent) => {
const newSortOrder = e.target.value;
setSortOrder(newSortOrder);
setCurrentPage(1);
if (activeCategory === 'all') {
fetchItems(undefined, newSortOrder);
} else {
const category = categories.find(c => c.name === activeCategory);
if (category) {
fetchItems(category.id, newSortOrder);
}
}
};
const handlePageChange = (page: number, size: number) => {
setCurrentPage(page);
setPageSize(size);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const paginatedItems = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
return items.slice(startIndex, endIndex);
}, [items, currentPage, pageSize]);
const getCategoryColor = (categoryName: string): string => {
const colorMap: Record<string, string> = {
'天气API': 'blue',
'数据API': 'cyan',
'AI接口': 'purple',
'实用API': 'green',
};
return colorMap[categoryName] || 'default';
};
const tabItems: TabsProps["items"] = [
{ key: 'all', label: '全部分类' },
...categories.map(cat => ({
key: cat.name,
label: cat.name,
})),
].map(cat => ({
key: cat.key,
label: cat.label,
children: (
<div className="option-radio-group">
<Radio.Group
onChange={handleSortChange}
value={sortOrder}
optionType="button"
buttonStyle="solid"
options={[
{ value: 1, label: '推荐排序' },
{ value: 2, label: '最多使用' },
{ value: 3, label: '最新上线' },
{ value: 4, label: '最多收藏' },
]}
/>
</div>
),
}));
if (loading && items.length === 0) {
return (
<div className="tools-hero">
<div className="tools-hero-content">
<Spin size="large" />
<p>加载中...</p>
</div>
</div>
);
}
return (
<>
<div className="tools-hero">
<div className="tools-hero-content">
<h1 className="tools-hero-title">
<ApiOutlined /> API接口大全
</h1>
<p className="tools-hero-subtitle">海量免费API接口,助力快速开发</p>
<p className="tools-hero-desc">提供天气查询、数据服务、AI接口等多种API,即调即用,轻松集成到您的项目中</p>
</div>
</div>
{error && (
<div className="tools-error-wrapper">
<Empty description={error}>
<Button type="primary" onClick={() => fetchItems()}>
<ReloadOutlined /> 重试
</Button>
</Empty>
</div>
)}
{!error && (
<>
<div className="tools-tabs-wrapper">
<Tabs
defaultActiveKey="all"
activeKey={activeCategory}
items={tabItems}
centered
className="tools-tabs"
onChange={handleCategoryChange}
/>
</div>
<div className="tools-card-grid">
{paginatedItems.length === 0 ? (
<div className="tools-empty-wrapper">
<Empty description="暂无API数据" />
</div>
) : (
paginatedItems.map((api, index) => (
<div
key={api.id}
className={`tools-feature-card fade-in${api.is_coming_soon ? " tools-feature-card--coming-soon" : ""}`}
style={{ animationDelay: `${index * 0.05}s`, cursor: api.is_coming_soon ? "default" : "pointer" }}
onClick={() => handleApiClick(api)}
>
<div className="tools-feature-card-cover">
<Image
height={160}
width="100%"
draggable={false}
alt={api.name}
src={getApiImageUrl(api)}
preview={false}
fallback="https://cdn.free-api.com/sjtxsc.webp"
className={api.name === '百度翻译' ? 'tools-feature-card-img--contain' : ''}
/>
<div className="tools-category-tag">
<Tag color={getCategoryColor(api.category_name)}>
{api.category_name || '其他'}
</Tag>
</div>
<div className="api-method-tag">
<Tag color={methodColors[api.method] || "default"}>{api.method}</Tag>
</div>
{api.is_coming_soon && (
<div className="api-coming-soon-badge">
<Tag color="warning">敬请期待</Tag>
</div>
)}
{api.is_new && (
<div className="api-new-badge">
<Tag color="error">NEW</Tag>
</div>
)}
</div>
<div className="tools-feature-card-body">
<div className="tools-feature-card-title">
<span>{api.name}</span>
<span className="tools-feature-card-views">
<EyeOutlined /> {api.views_count.toLocaleString()}
</span>
</div>
<div className="tools-feature-card-desc">{api.description}</div>
<div className="api-url">
<code>{api.url_path}</code>
</div>
<div className="tools-feature-card-footer">
<span
className={`tools-feature-card-stars api-favorite-btn ${api.is_favorited ? "is-favorited" : ""}`}
onClick={(e) => handleFavoriteToggle(api.id, e)}
>
{favoriteLoading[api.id] ? (
<LoadingOutlined />
) : api.is_favorited ? (
<StarFilled />
) : (
<StarOutlined />
)}
{" "}{api.favorites_count}
</span>
<div className="tools-feature-card-actions">
{api.is_coming_soon ? (
<Button type="default" size="small" disabled>
敬请期待
</Button>
) : (
<Button type="primary" size="small" icon={<LinkOutlined />} onClick={(e) => { e.stopPropagation(); handleApiClick(api); }}>
调用
</Button>
)}
</div>
</div>
</div>
</div>
))
)}
</div>
<div className="tools-pagination">
<Pagination
align="center"
total={items.length}
current={currentPage}
pageSize={pageSize}
showSizeChanger
showQuickJumper
showTotal={(total) => `共 ${total} 个接口`}
onChange={handlePageChange}
pageSizeOptions={['6', '12', '24']}
/>
</div>
</>
)}
</>
);
};
export default ApiDirectoryDesktop;