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全绿)。
400 lines
12 KiB
TypeScript
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;
|