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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user