diff --git a/src/pages/Articles/Articles.css b/src/pages/Articles/Articles.css index a64861d..cf28fca 100644 --- a/src/pages/Articles/Articles.css +++ b/src/pages/Articles/Articles.css @@ -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); +} diff --git a/src/pages/Articles/Articles.tsx b/src/pages/Articles/Articles.tsx index 0601f2f..315fbd3 100644 --- a/src/pages/Articles/Articles.tsx +++ b/src/pages/Articles/Articles.tsx @@ -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 = { @@ -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}`)} >
{
{article.author_name} +
@@ -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}`)} >
{
{article.author_name} +
@@ -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') }, ]} />
@@ -524,7 +581,25 @@ const Articles: React.FC = () => { )}
- {viewMode === "grid" ? renderGridView() : renderListView()} + {filteredArticles.length === 0 ? ( +
+ {sortBy === 5 ? ( + <> +

{t('articles.empty.followingTitle')}

+ {t('articles.empty.followingHint')} + + ) : ( + <> +

{t('articles.empty.title')}

+ {t('articles.empty.hint')} + + )} +
+ ) : viewMode === "grid" ? ( + renderGridView() + ) : ( + renderListView() + )}
{ const navigation = useNavigate(); const { t } = useTranslation(); + const isLogin = useSelector(selectIsLogin); + const { openLoginModal } = useLoginModal(); const [latestUpdates, setLatestUpdates] = useState([]); const [updatesLoading, setUpdatesLoading] = useState(true); const [hoveredCard, setHoveredCard] = useState(null); + const [topTools, setTopTools] = useState([]); + const [checkinStatus, setCheckinStatus] = useState(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: , gradient: "linear-gradient(135deg, #06b6d4, #0891b2)" }, { id: 2, nameKey: "aiText", descKey: "aiText", views: 45623, categoryKey: "ai", icon: , 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 = { + CodeOutlined: , + ClockCircleOutlined: , + FileTextOutlined: , + SearchOutlined: , + PictureOutlined: , + QrcodeOutlined: , + CalculatorOutlined: , + CalendarOutlined: , + CompressOutlined: , + TranslationOutlined: , + EditOutlined: , + ToolOutlined: , + ThunderboltOutlined: , + }; + return map[iconName] || ; + }; + + 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 = { "beginner": "#10b981", "intermediate": "#3b82f6", "advanced": "#ef4444", @@ -146,7 +224,7 @@ const Home: React.FC = () => {
-
navigation("/api")}> +
navigation("/open-api")}>
@@ -163,6 +241,41 @@ const Home: React.FC = () => {
+ {/* Daily Checkin */} +
+
+
+ {isLogin && checkinStatus?.signed_today ? : } +
+
+ + {isLogin + ? (checkinStatus?.signed_today ? t("home.checkin.signedToday") : t("home.checkin.notSignedToday")) + : t("home.checkin.title")} + + + {isLogin && checkinStatus + ? t("home.checkin.weekProgress", { count: checkinStatus.week_signed_count ?? 0 }) + : t("home.checkin.loginHint")} + +
+
+
+ {isLogin && checkinStatus && ( + +{checkinStatus.checkin_points ?? 0} {t("home.checkin.pointsUnit")} + )} + +
+
+ {/* Latest Updates */}
@@ -222,7 +335,7 @@ const Home: React.FC = () => {

{t("home.section.hotApi.desc")}

-
@@ -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")} >
@@ -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}`)} >
{
+ {/* Hot Tools Ranking */} +
+
+
+
+ +
+
+

{t("home.section.hotTools.title")}

+

{t("home.section.hotTools.desc")}

+
+
+ +
+ {topTools.length === 0 ? ( +
热门工具正在统计中,先去逛逛工具页吧
+ ) : ( +
+ {topTools.map((tool, index) => ( + +
tool.url_path && navigation(tool.url_path)} + > + {index + 1} +
+ {topToolIcon(tool.icon)} +
+ {tool.name} + + {tool.usage_count?.toLocaleString()} + + {(tool.weekly_count ?? 0) > 0 && ( + 本周 {tool.weekly_count} 人在用 + )} +
+
+ ))} +
+ )} +
+ {/* Utility Tools */}
diff --git a/src/pages/Home/HomeMobile.css b/src/pages/Home/HomeMobile.css index 8963dd8..17519a0 100644 --- a/src/pages/Home/HomeMobile.css +++ b/src/pages/Home/HomeMobile.css @@ -402,4 +402,63 @@ .home-mobile-hero-subtitle { hyphens: auto; word-break: normal; -} \ No newline at end of file +} +/* 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; +} diff --git a/src/pages/Home/HomeMobile.tsx b/src/pages/Home/HomeMobile.tsx index da62d80..8ac7e8a 100644 --- a/src/pages/Home/HomeMobile.tsx +++ b/src/pages/Home/HomeMobile.tsx @@ -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([]); + const [checkinStatus, setCheckinStatus] = useState(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: , color: "#667eea" }, { id: 2, nameKey: "utility", path: "/utility", icon: , color: "#f093fb" }, { id: 3, nameKey: "articles", path: "/articles", icon: , color: "#4facfe" }, - { id: 4, nameKey: "tools", path: "/api", icon: , color: "#43e97b" }, + { id: 4, nameKey: "tools", path: "/open-api", icon: , color: "#43e97b" }, ]; const hotApis = [ @@ -88,6 +127,39 @@ const HomeMobile: React.FC = () => { ))}
+ {/* C-06:移动端签到条(与桌面端 Home 同口径,未登录为引导态) */} +
+
+
+
+ {isLogin && checkinStatus?.signed_today ? : } +
+
+ + {isLogin + ? (checkinStatus?.signed_today ? t("home.checkin.signedToday") : t("home.checkin.notSignedToday")) + : t("home.checkin.title")} + + + {isLogin && checkinStatus + ? t("home.checkin.weekProgress", { count: checkinStatus.week_signed_count ?? 0 }) + : t("home.checkin.loginHint")} + +
+
+ +
+
+ {latestUpdates.length > 0 && (
@@ -135,7 +207,7 @@ const HomeMobile: React.FC = () => {

{t("home.section.hotApi.title")}

- navigate("/api")}> + navigate("/open-api")}> {t("home.section.more")}
@@ -144,7 +216,7 @@ const HomeMobile: React.FC = () => {
navigate("/api")} + onClick={() => navigate("/open-api")} >
{api.icon} diff --git a/src/pages/Learn/Learn.tsx b/src/pages/Learn/Learn.tsx index c3d42ff..ccb12de 100644 --- a/src/pages/Learn/Learn.tsx +++ b/src/pages/Learn/Learn.tsx @@ -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 = () => {

{course.desc}

{course.chapters_count} {t('learn.chapters')} - {(course.students / 1000).toFixed(1)}k {t('learn.students')} + {course.students >= 1000 ? `${(course.students / 1000).toFixed(1)}k` : course.students} {t('learn.students')} + {course.total_minutes != null && ( + {course.total_minutes} {t('learn.minutes')} + )}
diff --git a/src/pages/Learn/LearnMobile.tsx b/src/pages/Learn/LearnMobile.tsx index f91f158..4c6f5aa 100644 --- a/src/pages/Learn/LearnMobile.tsx +++ b/src/pages/Learn/LearnMobile.tsx @@ -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')} · - {(course.students / 1000).toFixed(1)}k {t('learn.students')} + {course.students >= 1000 ? `${(course.students / 1000).toFixed(1)}k` : course.students} {t('learn.students')} + {course.total_minutes != null && ( + <> + · + {course.total_minutes} {t('learn.minutes')} + + )} · {course.level} diff --git a/src/pages/Top/Top.css b/src/pages/Top/Top.css new file mode 100644 index 0000000..50221ce --- /dev/null +++ b/src/pages/Top/Top.css @@ -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; +} diff --git a/src/pages/Top/Top.tsx b/src/pages/Top/Top.tsx new file mode 100644 index 0000000..cb1dd42 --- /dev/null +++ b/src/pages/Top/Top.tsx @@ -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 = { + CodeOutlined: {""}, + ToolOutlined: , + 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([]); + 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 ( +
+
+
+ +
+

工具热榜

+

大家都在用的效率工具 · 每周更新

+ setRange(v as "all" | "week")} + options={[ + { value: "all", label: "总榜" }, + { value: "week", label: "周榜" }, + ]} + /> +
+
+ {loading ? ( +
+ +
+ ) : tools.length === 0 ? ( + + ) : ( + tools.map((tool) => ( +
tool.url_path && navigate(tool.url_path)} + > + + {tool.rank ?? "-"} + + + {iconMap[tool.icon] || } + +
+ {tool.name} + {tool.description} +
+
+ + {(tool.usage_count ?? 0).toLocaleString()} + + {(tool.weekly_count ?? 0) > 0 && ( + 本周 {tool.weekly_count} 人在用 + )} +
+ +
+ )) + )} +
+
+ ); +}; + +export default Top; diff --git a/src/utils/request.ts b/src/utils/request.ts index 20f2f84..997ae41 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -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,