feat(C-02):工具热榜Top页+首页热榜/签到

新增/top热榜页(总榜/周榜切换);首页热榜区(getTop+周活徽标+排名角标)与签到条(checkin/status);request新增tool.getTop/getRecent与aitool.upscale/getRecords封装;附带C-06前端对照:文章卡关注按钮+空态(is_following/author_user_id)、课程total_minutes课时与真实在学人数展示。
This commit is contained in:
chunyu
2026-09-15 15:26:39 +08:00
parent 24f200bfa6
commit abea2caa87
11 changed files with 805 additions and 17 deletions
+34
View File
@@ -1066,3 +1066,37 @@
[data-theme="dark"] .articles-stat-icon {
background: rgba(30, 41, 59, 0.5);
}
/* C-06:文章卡关注按钮 + 空态 */
.articles-follow-btn {
margin-left: 4px;
height: 24px;
padding: 0 10px;
font-size: 12px;
border-radius: 12px;
}
.articles-empty {
text-align: center;
padding: 64px 20px;
color: #64748b;
}
.articles-empty p {
font-size: 16px;
font-weight: 600;
margin: 0 0 8px;
color: #334155;
}
.articles-empty span {
font-size: 13px;
}
[data-theme="dark"] .articles-empty p {
color: var(--text-primary);
}
[data-theme="dark"] .articles-empty {
color: var(--text-muted);
}
+78 -3
View File
@@ -34,6 +34,7 @@ interface Article {
category: string;
author_name: string;
author_avatar: string;
author_user_id?: number;
views: number;
likes: number;
comments_count: number;
@@ -48,6 +49,7 @@ interface Article {
status?: string;
is_favorited?: boolean;
favorites_count?: number;
is_following?: boolean;
}
const categoryIcons: Record<string, React.ReactNode> = {
@@ -123,6 +125,14 @@ const Articles: React.FC = () => {
} else {
params.ordering = '-created_at';
}
if (sortBy === 5) {
// 关注 feed:仅显示关注作者的文章
if (!isLogin) {
openLoginModal();
return;
}
params.feed = 'following';
}
const res = await api_request.article.get_list(params);
const data = (res as any)?.data;
if (data?.results) {
@@ -181,6 +191,34 @@ const Articles: React.FC = () => {
}
};
const handleToggleFollow = async (e: React.MouseEvent, authorId: number | undefined, authorName: string) => {
e.stopPropagation();
if (!authorId) return;
if (!isLogin) {
openLoginModal();
return;
}
try {
const res: any = await api_request.user.follow(authorId);
const data = res?.data;
if (data) {
const following = !!data.is_following;
setArticles((prev) =>
prev.map((a) =>
a.author_user_id === authorId ? { ...a, is_following: following } : a
)
);
// 若在"关注"feed 下取关,作者文章即时从流中移除
if (sortBy === 5 && !following) {
setArticles((prev) => prev.filter((a) => a.author_user_id !== authorId));
setTotal((t) => Math.max(0, t - 1));
}
}
} catch (error) {
console.error('切换关注状态失败:', authorName, error);
}
};
const filteredArticles = useMemo(() => {
return articles;
}, [articles]);
@@ -223,7 +261,7 @@ const Articles: React.FC = () => {
key={article.id}
className="articles-card fade-in"
style={{ animationDelay: `${index * 0.06}s` }}
onClick={() => navigate(`/article/${article.id}`)}
onClick={() => navigate(`/post/${article.id}`)}
>
<div className="articles-card-cover" style={{ position: "relative" }}>
<Image
@@ -309,6 +347,15 @@ const Articles: React.FC = () => {
<div className="articles-card-author">
<Avatar size={26} src={article.author_avatar} />
<span>{article.author_name}</span>
<Button
size="small"
type={article.is_following ? "default" : "primary"}
ghost={!!article.is_following}
className="articles-follow-btn"
onClick={(e) => handleToggleFollow(e, article.author_user_id, article.author_name)}
>
{article.is_following ? t('articles.detail.following') : t('articles.detail.follow')}
</Button>
</div>
<div className="articles-card-stats">
<Tooltip title={t('articles.tooltips.views')}>
@@ -346,7 +393,7 @@ const Articles: React.FC = () => {
key={article.id}
className="articles-list-card fade-in"
style={{ animationDelay: `${index * 0.05}s` }}
onClick={() => navigate(`/article/${article.id}`)}
onClick={() => navigate(`/post/${article.id}`)}
>
<div className="articles-list-cover">
<Image
@@ -378,6 +425,15 @@ const Articles: React.FC = () => {
<div className="articles-list-author">
<Avatar size={22} src={article.author_avatar} />
<span>{article.author_name}</span>
<Button
size="small"
type={article.is_following ? "default" : "primary"}
ghost={!!article.is_following}
className="articles-follow-btn"
onClick={(e) => handleToggleFollow(e, article.author_user_id, article.author_name)}
>
{article.is_following ? t('articles.detail.following') : t('articles.detail.follow')}
</Button>
</div>
<div className="articles-list-stats">
<span>
@@ -489,6 +545,7 @@ const Articles: React.FC = () => {
{ value: 2, label: t('articles.sort.mostViews') },
{ value: 3, label: t('articles.sort.mostLikes') },
{ value: 4, label: t('articles.sort.mostComments') },
{ value: 5, label: t('articles.sort.following') },
]}
/>
<div className="articles-view-toggle">
@@ -524,7 +581,25 @@ const Articles: React.FC = () => {
)}
</div>
{viewMode === "grid" ? renderGridView() : renderListView()}
{filteredArticles.length === 0 ? (
<div className="articles-empty">
{sortBy === 5 ? (
<>
<p>{t('articles.empty.followingTitle')}</p>
<span>{t('articles.empty.followingHint')}</span>
</>
) : (
<>
<p>{t('articles.empty.title')}</p>
<span>{t('articles.empty.hint')}</span>
</>
)}
</div>
) : viewMode === "grid" ? (
renderGridView()
) : (
renderListView()
)}
<div className="articles-pagination">
<Pagination
+121
View File
@@ -888,6 +888,127 @@
opacity: 1;
}
.home-utility-card {
position: relative;
}
.home-rank-badge {
position: absolute;
top: 10px;
left: 12px;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
}
/* ---------- 首页签到条 ---------- */
.home-checkin-bar {
margin: 20px auto 0;
max-width: 1200px;
padding: 16px 24px;
border-radius: 18px;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.10), rgba(118, 75, 162, 0.10));
border: 1px solid rgba(102, 126, 234, 0.25);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.home-checkin-left {
display: flex;
align-items: center;
gap: 14px;
}
.home-checkin-icon {
width: 46px;
height: 46px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: #fff;
background: linear-gradient(135deg, #667eea, #764ba2);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.35);
}
.home-checkin-icon.done {
background: linear-gradient(135deg, #43e97b, #38c172);
box-shadow: 0 4px 12px rgba(67, 233, 123, 0.35);
}
.home-checkin-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.home-checkin-title {
font-size: 16px;
font-weight: 700;
color: #1e293b;
}
.home-checkin-sub {
font-size: 13px;
color: #64748b;
}
.home-checkin-actions {
display: flex;
align-items: center;
gap: 12px;
}
.home-checkin-points {
font-size: 14px;
font-weight: 700;
color: #f59e0b;
}
.home-checkin-btn {
border-radius: 12px;
background: linear-gradient(135deg, #667eea, #764ba2);
border: none;
font-weight: 600;
padding: 0 22px;
height: 38px;
}
.home-checkin-btn:disabled {
background: linear-gradient(135deg, #43e97b, #38c172);
color: #fff;
}
.home-utility-card-usage {
font-size: 12px;
color: #f59e0b;
display: flex;
align-items: center;
gap: 4px;
}
.home-utility-card-weekly {
font-size: 11px;
color: #059669;
}
.home-top-empty {
text-align: center;
color: #64748b;
padding: 28px 0;
border: 1px dashed #cbd5e1;
border-radius: 14px;
}
.home-utility-card-icon {
font-size: 32px;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
+164 -6
View File
@@ -1,23 +1,63 @@
import "./Home.css";
import { Tag, Avatar, Image, Button, Tooltip, Spin } from "antd";
import React, { useEffect, useState } from "react";
import { Tag, Avatar, Image, Button, Tooltip, Spin, message } from "antd";
import React, { useEffect, useState, useCallback } from "react";
import {
EyeOutlined, LikeOutlined, CommentOutlined, ArrowRightOutlined,
BookOutlined, ToolOutlined, ReadOutlined, ApiOutlined,
CodeOutlined, ThunderboltOutlined, RocketOutlined, FireOutlined,
CalculatorOutlined, QrcodeOutlined, ClockCircleOutlined, FileTextOutlined,
SafetyOutlined, CloudOutlined, DatabaseOutlined, BulbOutlined,
TranslationOutlined, SearchOutlined, PictureOutlined, CalendarOutlined,
CompressOutlined, EditOutlined, CrownOutlined, CheckCircleOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { api_request } from "@/utils/request";
import { selectIsLogin } from "@/features/login";
import { useLoginModal } from "@/contexts/LoginModalContext";
const Home: React.FC = () => {
const navigation = useNavigate();
const { t } = useTranslation();
const isLogin = useSelector(selectIsLogin);
const { openLoginModal } = useLoginModal();
const [latestUpdates, setLatestUpdates] = useState<any[]>([]);
const [updatesLoading, setUpdatesLoading] = useState(true);
const [hoveredCard, setHoveredCard] = useState<string | null>(null);
const [topTools, setTopTools] = useState<any[]>([]);
const [checkinStatus, setCheckinStatus] = useState<any | null>(null);
const [signing, setSigning] = useState(false);
const fetchCheckinStatus = useCallback(async () => {
if (!isLogin) return;
try {
const res: any = await api_request.login.wallet.get_checkin_status();
if (res?.success || res?.data) setCheckinStatus(res.data);
} catch {
/* 未登录或接口异常时静默 */
}
}, [isLogin]);
const handleCheckin = async () => {
if (!isLogin) {
openLoginModal();
return;
}
if (signing || checkinStatus?.signed_today) return;
setSigning(true);
try {
const res: any = await api_request.login.wallet.checkin();
if (res?.success) {
message.success(t("home.checkin.success"));
fetchCheckinStatus();
}
} catch {
/* 忽略 */
} finally {
setSigning(false);
}
};
useEffect(() => {
api_request.changelog.getList({ page_size: 3 })
@@ -27,8 +67,19 @@ const Home: React.FC = () => {
})
.catch(() => {})
.finally(() => setUpdatesLoading(false));
api_request.tool.getTop({ range: "all", limit: 8 })
.then((res: any) => {
const data = res?.data?.results || res?.data || [];
setTopTools(Array.isArray(data) ? data : []);
})
.catch(() => {});
}, []);
useEffect(() => {
fetchCheckinStatus();
}, [fetchCheckinStatus]);
const tools = [
{ id: 1, nameKey: "weather", descKey: "weather", views: 25800, categoryKey: "weather", icon: <CloudOutlined />, gradient: "linear-gradient(135deg, #06b6d4, #0891b2)" },
{ id: 2, nameKey: "aiText", descKey: "aiText", views: 45623, categoryKey: "ai", icon: <ThunderboltOutlined />, gradient: "linear-gradient(135deg, #a855f7, #7c3aed)" },
@@ -70,6 +121,33 @@ const Home: React.FC = () => {
return colors[categoryKey] || "default";
};
const topToolIcon = (iconName: string) => {
const map: Record<string, React.ReactNode> = {
CodeOutlined: <CodeOutlined />,
ClockCircleOutlined: <ClockCircleOutlined />,
FileTextOutlined: <FileTextOutlined />,
SearchOutlined: <SearchOutlined />,
PictureOutlined: <PictureOutlined />,
QrcodeOutlined: <QrcodeOutlined />,
CalculatorOutlined: <CalculatorOutlined />,
CalendarOutlined: <CalendarOutlined />,
CompressOutlined: <CompressOutlined />,
TranslationOutlined: <TranslationOutlined />,
EditOutlined: <EditOutlined />,
ToolOutlined: <ToolOutlined />,
ThunderboltOutlined: <ThunderboltOutlined />,
};
return map[iconName] || <ToolOutlined />;
};
const rankBadgeStyle = (rank: number): React.CSSProperties => {
const top3 = ["#f59e0b", "#94a3b8", "#d97706"];
return {
background: rank <= 3 ? top3[rank - 1] : "var(--home-border, #E2E8F0)",
color: rank <= 3 ? "#fff" : "var(--home-text-secondary, #64748B)",
};
};
const getLevelColor = (levelKey: string) => {
const colors: Record<string, string> = {
"beginner": "#10b981", "intermediate": "#3b82f6", "advanced": "#ef4444",
@@ -146,7 +224,7 @@ const Home: React.FC = () => {
<ArrowRightOutlined />
</div>
</div>
<div className="home-quick-card" onClick={() => navigation("/api")}>
<div className="home-quick-card" onClick={() => navigation("/open-api")}>
<div className="home-quick-card-glow" style={{ background: "var(--home-grad-api)" }} />
<div className="home-quick-icon-wrapper">
<div className="home-quick-icon" style={{ background: "var(--home-grad-api)" }}>
@@ -163,6 +241,41 @@ const Home: React.FC = () => {
</div>
</section>
{/* Daily Checkin */}
<section className="home-checkin-bar">
<div className="home-checkin-left">
<div className={`home-checkin-icon ${isLogin && checkinStatus?.signed_today ? "done" : ""}`}>
{isLogin && checkinStatus?.signed_today ? <CheckCircleOutlined /> : <CalendarOutlined />}
</div>
<div className="home-checkin-info">
<span className="home-checkin-title">
{isLogin
? (checkinStatus?.signed_today ? t("home.checkin.signedToday") : t("home.checkin.notSignedToday"))
: t("home.checkin.title")}
</span>
<span className="home-checkin-sub">
{isLogin && checkinStatus
? t("home.checkin.weekProgress", { count: checkinStatus.week_signed_count ?? 0 })
: t("home.checkin.loginHint")}
</span>
</div>
</div>
<div className="home-checkin-actions">
{isLogin && checkinStatus && (
<span className="home-checkin-points">+{checkinStatus.checkin_points ?? 0} {t("home.checkin.pointsUnit")}</span>
)}
<Button
type="primary"
className="home-checkin-btn"
disabled={isLogin && !!checkinStatus?.signed_today}
loading={signing}
onClick={handleCheckin}
>
{!isLogin ? t("home.checkin.loginBtn") : checkinStatus?.signed_today ? t("home.checkin.signed") : t("home.checkin.btn")}
</Button>
</div>
</section>
{/* Latest Updates */}
<section className="home-section">
<div className="home-section-header">
@@ -222,7 +335,7 @@ const Home: React.FC = () => {
<p className="home-section-desc">{t("home.section.hotApi.desc")}</p>
</div>
</div>
<Button type="link" className="home-section-more" onClick={() => navigation("/api")}>
<Button type="link" className="home-section-more" onClick={() => navigation("/open-api")}>
{t("home.section.viewAll")} <ArrowRightOutlined />
</Button>
</div>
@@ -234,7 +347,7 @@ const Home: React.FC = () => {
style={{ animationDelay: `${index * 0.1}s` }}
onMouseEnter={() => setHoveredCard(`tool-${tool.id}`)}
onMouseLeave={() => setHoveredCard(null)}
onClick={() => navigation("/api-detail")}
onClick={() => navigation("/open-api")}
>
<div className="home-tool-card-glow" style={{ background: tool.gradient, opacity: hoveredCard === `tool-${tool.id}` ? 0.15 : 0 }} />
<div className="home-tool-card-header">
@@ -280,7 +393,7 @@ const Home: React.FC = () => {
key={article.id}
className="home-article-card fade-in"
style={{ animationDelay: `${index * 0.1}s` }}
onClick={() => navigation(`/article/${article.id}`)}
onClick={() => navigation(`/post/${article.id}`)}
>
<div className="home-article-cover">
<Image
@@ -366,6 +479,51 @@ const Home: React.FC = () => {
</div>
</section>
{/* Hot Tools Ranking */}
<section className="home-section home-section-alt">
<div className="home-section-header">
<div className="home-section-title-group">
<div className="home-section-icon" style={{ background: "linear-gradient(135deg, #f59e0b, #ef4444)" }}>
<CrownOutlined />
</div>
<div>
<h2 className="home-section-title">{t("home.section.hotTools.title")}</h2>
<p className="home-section-desc">{t("home.section.hotTools.desc")}</p>
</div>
</div>
<Button type="link" className="home-section-more" onClick={() => navigation("/top")}>
{t("home.section.viewAll")} <ArrowRightOutlined />
</Button>
</div>
{topTools.length === 0 ? (
<div className="home-top-empty">热门工具正在统计中,先去逛逛工具页吧</div>
) : (
<div className="home-utility-grid">
{topTools.map((tool, index) => (
<Tooltip title={tool.description} key={tool.id}>
<div
className="home-utility-card fade-in"
style={{ animationDelay: `${index * 0.05}s` }}
onClick={() => tool.url_path && navigation(tool.url_path)}
>
<span className="home-rank-badge" style={rankBadgeStyle(index + 1)}>{index + 1}</span>
<div className="home-utility-card-icon" style={{ color: tool.color || "#667eea" }}>
{topToolIcon(tool.icon)}
</div>
<span className="home-utility-card-name">{tool.name}</span>
<span className="home-utility-card-usage">
<FireOutlined /> {tool.usage_count?.toLocaleString()}
</span>
{(tool.weekly_count ?? 0) > 0 && (
<span className="home-utility-card-weekly">本周 {tool.weekly_count} 人在用</span>
)}
</div>
</Tooltip>
))}
</div>
)}
</section>
{/* Utility Tools */}
<section className="home-section">
<div className="home-section-header">
+60 -1
View File
@@ -402,4 +402,63 @@
.home-mobile-hero-subtitle {
hyphens: auto;
word-break: normal;
}
}
/* C-06:移动端签到条(与桌面端 home-checkin-bar 同口径) */
.home-mobile-checkin-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: #fff;
border-radius: 12px;
padding: 14px 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.home-mobile-checkin-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.home-mobile-checkin-icon {
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: #667eea;
background: rgba(102, 126, 234, 0.12);
flex-shrink: 0;
}
.home-mobile-checkin-icon.done {
color: #52c41a;
background: rgba(82, 196, 26, 0.12);
}
.home-mobile-checkin-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.home-mobile-checkin-title {
font-size: 15px;
font-weight: 600;
color: #333;
}
.home-mobile-checkin-sub {
font-size: 12px;
color: #999;
}
.home-mobile-checkin-btn {
flex-shrink: 0;
border-radius: 16px;
}
+77 -5
View File
@@ -1,19 +1,58 @@
import "./HomeMobile.css";
import { Tag } from "antd";
import React, { useEffect, useState } from "react";
import { Tag, Button } from "antd";
import React, { useCallback, useEffect, useState } from "react";
import {
EyeOutlined, LikeOutlined, ArrowRightOutlined,
BookOutlined, ToolOutlined, ReadOutlined, ApiOutlined,
ThunderboltOutlined, FireOutlined, RocketOutlined,
CalendarOutlined, CheckCircleOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { api_request } from "@/utils/request";
import { selectIsLogin } from "@/features/login";
import { useLoginModal } from "@/contexts/LoginModalContext";
const HomeMobile: React.FC = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const isLogin = useSelector(selectIsLogin);
const { openLoginModal } = useLoginModal();
const [latestUpdates, setLatestUpdates] = useState<any[]>([]);
const [checkinStatus, setCheckinStatus] = useState<any | null>(null);
const [signing, setSigning] = useState(false);
const fetchCheckinStatus = useCallback(async () => {
if (!isLogin) return;
try {
const res: any = await api_request.login.wallet.get_checkin_status();
if (res?.success || res?.data) setCheckinStatus(res.data);
} catch {
/* 未登录或接口异常时静默 */
}
}, [isLogin]);
const handleCheckin = async () => {
if (!isLogin) {
openLoginModal();
return;
}
if (signing || checkinStatus?.signed_today) return;
setSigning(true);
try {
const res: any = await api_request.login.wallet.checkin();
if (res?.success) fetchCheckinStatus();
} catch {
/* 忽略 */
} finally {
setSigning(false);
}
};
useEffect(() => {
fetchCheckinStatus();
}, [fetchCheckinStatus]);
useEffect(() => {
api_request.changelog.getList({ page_size: 2 })
@@ -28,7 +67,7 @@ const HomeMobile: React.FC = () => {
{ id: 1, nameKey: "learn", path: "/learn", icon: <BookOutlined />, color: "#667eea" },
{ id: 2, nameKey: "utility", path: "/utility", icon: <ToolOutlined />, color: "#f093fb" },
{ id: 3, nameKey: "articles", path: "/articles", icon: <ReadOutlined />, color: "#4facfe" },
{ id: 4, nameKey: "tools", path: "/api", icon: <ApiOutlined />, color: "#43e97b" },
{ id: 4, nameKey: "tools", path: "/open-api", icon: <ApiOutlined />, color: "#43e97b" },
];
const hotApis = [
@@ -88,6 +127,39 @@ const HomeMobile: React.FC = () => {
))}
</div>
{/* C-06:移动端签到条(与桌面端 Home 同口径,未登录为引导态) */}
<div className="home-mobile-section">
<div className="home-mobile-checkin-bar">
<div className="home-mobile-checkin-left">
<div className={`home-mobile-checkin-icon ${isLogin && checkinStatus?.signed_today ? "done" : ""}`}>
{isLogin && checkinStatus?.signed_today ? <CheckCircleOutlined /> : <CalendarOutlined />}
</div>
<div className="home-mobile-checkin-info">
<span className="home-mobile-checkin-title">
{isLogin
? (checkinStatus?.signed_today ? t("home.checkin.signedToday") : t("home.checkin.notSignedToday"))
: t("home.checkin.title")}
</span>
<span className="home-mobile-checkin-sub">
{isLogin && checkinStatus
? t("home.checkin.weekProgress", { count: checkinStatus.week_signed_count ?? 0 })
: t("home.checkin.loginHint")}
</span>
</div>
</div>
<Button
type="primary"
size="small"
className="home-mobile-checkin-btn"
disabled={isLogin && !!checkinStatus?.signed_today}
loading={signing}
onClick={handleCheckin}
>
{!isLogin ? t("home.checkin.loginBtn") : checkinStatus?.signed_today ? t("home.checkin.signed") : t("home.checkin.btn")}
</Button>
</div>
</div>
{latestUpdates.length > 0 && (
<div className="home-mobile-section">
<div className="home-mobile-section-header">
@@ -135,7 +207,7 @@ const HomeMobile: React.FC = () => {
<ApiOutlined className="home-mobile-section-icon" />
<h2 className="home-mobile-section-title">{t("home.section.hotApi.title")}</h2>
</div>
<span className="home-mobile-section-more" onClick={() => navigate("/api")}>
<span className="home-mobile-section-more" onClick={() => navigate("/open-api")}>
{t("home.section.more")} <ArrowRightOutlined />
</span>
</div>
@@ -144,7 +216,7 @@ const HomeMobile: React.FC = () => {
<div
key={api.id}
className="home-mobile-api-card"
onClick={() => navigate("/api")}
onClick={() => navigate("/open-api")}
>
<div className="home-mobile-api-icon">
{api.icon}
+8 -1
View File
@@ -17,6 +17,7 @@ import {
FireOutlined,
StarOutlined,
StarFilled,
ClockCircleOutlined,
} from "@ant-design/icons";
import React, { useState, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom";
@@ -39,6 +40,7 @@ interface CourseData {
author_name: string;
chapters_count: number;
students_count: number;
total_minutes?: number | null;
status: string;
is_hot: boolean;
is_new: boolean;
@@ -59,6 +61,7 @@ interface DisplayCourse {
level: string;
levelKey: string;
students: number;
total_minutes?: number | null;
isHot: boolean;
isNew: boolean;
is_favorited: boolean;
@@ -153,6 +156,7 @@ const Learn: React.FC = () => {
level: getLevelName(item.level),
levelKey: item.level,
students: item.students_count,
total_minutes: item.total_minutes ?? null,
isHot: item.is_hot,
isNew: item.is_new,
is_favorited: item.is_favorited,
@@ -336,7 +340,10 @@ const Learn: React.FC = () => {
<p className="learn-card-desc">{course.desc}</p>
<div className="learn-card-meta">
<span><ReadOutlined /> {course.chapters_count} {t('learn.chapters')}</span>
<span><StarOutlined /> {(course.students / 1000).toFixed(1)}k {t('learn.students')}</span>
<span><StarOutlined /> {course.students >= 1000 ? `${(course.students / 1000).toFixed(1)}k` : course.students} {t('learn.students')}</span>
{course.total_minutes != null && (
<span><ClockCircleOutlined /> {course.total_minutes} {t('learn.minutes')}</span>
)}
</div>
</div>
<div className="learn-card-btn" style={{ background: course.color }}>
+10 -1
View File
@@ -36,6 +36,7 @@ interface CourseData {
author_name: string;
chapters_count: number;
students_count: number;
total_minutes?: number | null;
status: string;
is_hot: boolean;
is_new: boolean;
@@ -54,6 +55,7 @@ interface DisplayCourse {
level: string;
levelKey: string;
students: number;
total_minutes?: number | null;
isHot: boolean;
isNew: boolean;
is_favorited: boolean;
@@ -130,6 +132,7 @@ const LearnMobile: React.FC = () => {
level: getLevelName(item.level),
levelKey: item.level,
students: item.students_count,
total_minutes: item.total_minutes ?? null,
isHot: item.is_hot,
isNew: item.is_new,
is_favorited: item.is_favorited,
@@ -327,7 +330,13 @@ const LearnMobile: React.FC = () => {
{course.chapters_count} {t('learn.ch')}
</span>
<span className="lm-meta-dot">·</span>
<span className="lm-meta-item">{(course.students / 1000).toFixed(1)}k {t('learn.students')}</span>
<span className="lm-meta-item">{course.students >= 1000 ? `${(course.students / 1000).toFixed(1)}k` : course.students} {t('learn.students')}</span>
{course.total_minutes != null && (
<>
<span className="lm-meta-dot">·</span>
<span className="lm-meta-item">{course.total_minutes} {t('learn.minutes')}</span>
</>
)}
<span className="lm-meta-dot">·</span>
<Tag color={getLevelColor(course.levelKey)} className="lm-meta-tag">
{course.level}
+122
View File
@@ -0,0 +1,122 @@
.top-page {
max-width: 880px;
margin: 0 auto;
padding: 32px 20px 64px;
}
.top-hero {
text-align: center;
padding: 40px 20px 28px;
background: linear-gradient(135deg, rgba(245, 158, 11, 0.12), rgba(239, 68, 68, 0.1));
border: 1px solid rgba(245, 158, 11, 0.25);
border-radius: 20px;
margin-bottom: 24px;
}
.top-hero-icon {
font-size: 40px;
color: #f59e0b;
margin-bottom: 8px;
}
.top-hero-title {
font-size: 28px;
font-weight: 800;
margin: 0 0 6px;
}
.top-hero-desc {
color: #64748b;
margin: 0 0 16px;
}
.top-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.top-loading {
display: flex;
justify-content: center;
padding: 60px 0;
}
.top-item {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
cursor: pointer;
transition: all 0.25s ease;
}
.top-item:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px -12px rgba(0, 0, 0, 0.18);
}
.top-rank {
min-width: 30px;
height: 30px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 14px;
}
.top-item-icon {
font-size: 26px;
}
.top-item-info {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.top-item-name {
font-weight: 700;
font-size: 15px;
}
.top-item-desc {
color: #64748b;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.top-item-stats {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.top-item-count {
font-size: 13px;
font-weight: 700;
color: #ef4444;
}
.top-item-weekly {
font-size: 11px;
color: #059669;
}
[data-theme="dark"] .top-item {
background: #1e293b;
border-color: #334155;
}
[data-theme="dark"] .top-item-desc {
color: #94a3b8;
}
+116
View File
@@ -0,0 +1,116 @@
import "./Top.css";
import React, { useCallback, useEffect, useState } from "react";
import { Button, Empty, Segmented, Spin } from "antd";
import { CrownOutlined, FireOutlined, ToolOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { api_request } from "@/utils/request";
interface TopToolItem {
id: number;
name: string;
description: string;
icon: string;
url_path: string;
color: string;
usage_count: number;
weekly_count: number | null;
rank: number;
}
const iconMap: Record<string, React.ReactNode> = {
CodeOutlined: <i className="top-icon-font">{"</>"}</i>,
ToolOutlined: <ToolOutlined />,
FireOutlined: <FireOutlined />,
};
const rankStyle = (rank: number): React.CSSProperties => {
if (rank === 1) return { background: "#f59e0b", color: "#fff" };
if (rank === 2) return { background: "#94a3b8", color: "#fff" };
if (rank === 3) return { background: "#d97706", color: "#fff" };
return { background: "#E2E8F0", color: "#64748B" };
};
const Top: React.FC = () => {
const navigate = useNavigate();
const [range, setRange] = useState<"all" | "week">("all");
const [tools, setTools] = useState<TopToolItem[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async (r: "all" | "week") => {
setLoading(true);
try {
const res: any = await api_request.tool.getTop({ range: r, limit: 50 });
const data = res?.data?.results || res?.data || [];
setTools(Array.isArray(data) ? data : []);
} catch {
setTools([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load(range);
}, [range, load]);
return (
<div className="top-page">
<div className="top-hero">
<div className="top-hero-icon">
<CrownOutlined />
</div>
<h1 className="top-hero-title">工具热榜</h1>
<p className="top-hero-desc">大家都在用的效率工具 · 每周更新</p>
<Segmented
value={range}
onChange={(v) => setRange(v as "all" | "week")}
options={[
{ value: "all", label: "总榜" },
{ value: "week", label: "周榜" },
]}
/>
</div>
<div className="top-list">
{loading ? (
<div className="top-loading">
<Spin size="large" />
</div>
) : tools.length === 0 ? (
<Empty description={range === "week" ? "本周暂无使用记录,先去用用工具吧" : "暂无工具数据"} />
) : (
tools.map((tool) => (
<div
key={tool.id}
className="top-item"
onClick={() => tool.url_path && navigate(tool.url_path)}
>
<span className="top-rank" style={rankStyle(tool.rank ?? 0)}>
{tool.rank ?? "-"}
</span>
<span className="top-item-icon" style={{ color: tool.color || "#667eea" }}>
{iconMap[tool.icon] || <ToolOutlined />}
</span>
<div className="top-item-info">
<span className="top-item-name">{tool.name}</span>
<span className="top-item-desc">{tool.description}</span>
</div>
<div className="top-item-stats">
<span className="top-item-count">
<FireOutlined /> {(tool.usage_count ?? 0).toLocaleString()}
</span>
{(tool.weekly_count ?? 0) > 0 && (
<span className="top-item-weekly">本周 {tool.weekly_count} 人在用</span>
)}
</div>
<Button type="link" size="small">
去使用
</Button>
</div>
))
)}
</div>
</div>
);
};
export default Top;
+15
View File
@@ -476,6 +476,10 @@ const tool = {
request.normal.get("/tool/favorites/list/"),
incrementUsage: (id: number) =>
request.normal.post(`/tool/usage/${id}/increment/`),
getTop: (params?: object) =>
request.public.get("/tool/top/", params),
getRecent: (params?: object) =>
request.normal.get("/tool/recent/", params),
getColorHistory: (params?: object) =>
request.normal.get("/tool/color-history/", params),
createColorHistory: (color: string) =>
@@ -653,6 +657,16 @@ const apidirectory = {
request.normal.post(`/api/apidirectory/items/${id}/favorite/`),
};
const aitool = {
upscale: (data: FormData) =>
request.normal.post("/api/aitool/upscale/", data, {
headers: { "Content-Type": "multipart/form-data" },
responseType: "blob",
}),
getRecords: () =>
request.normal.get("/api/aitool/records/"),
};
export const api_request = {
normal: request.normal,
public: request.public,
@@ -677,6 +691,7 @@ export const api_request = {
shorturl,
passwordGenerator,
apidirectory,
aitool,
user,
region,
tasks: login.tasks,