diff --git a/mobile-login-templates.html b/mobile-login-templates.html new file mode 100644 index 0000000..6048657 --- /dev/null +++ b/mobile-login-templates.html @@ -0,0 +1,581 @@ + + + + + +可乐Web - 移动端登录/注册模板预览 + + + + +
+

可乐Web - 移动端登录/注册模板预览

+
+ +
+ + + + +
+ + +
+
模板A - 极简现代风 | 参考:微信/支付宝风格,干净清爽
+
+
+
+
+
+ +

欢迎回来

+

登录可乐Web,探索更多可能

+
+
+
+
密码登录
+
验证码登录
+
+ +
+
+ +
+
+ +
+ + + +
+ + + +
其他登录方式
+
+
🐧
+
💬
+
🔴
+
+
+ 登录即表示同意 用户协议 和 隐私政策 +
+
+
+
+
+
+ + +
+
模板B - 深色卡片风 | 参考:Spotify/Discord风格,科技感十足
+
+
+
+
+
+ +

可乐Web

+

你的全能工具平台

+
+
+
+
账号密码
+
邮箱验证码
+
+ +
+
+ + +
+
+ + +
+ + + +
+ + + +
或使用第三方账号
+
+
🐧
+
💬
+
🔴
+
+
+
+ 继续即表示同意 用户协议 与 隐私政策 +
+
+
+
+
+ + +
+
模板C - 玻璃拟态风 | 参考:iOS/Apple风格,高级感拉满
+
+
+
+
+
+
+
+
+
+
🥤
+

可乐Web

+

随时随地,高效创作

+
+ +
+
+ 👤 + +
+
+ 🔒 + +
+ + +
+ + + +
+
— 其他方式 —
+
+
🔑
+
🐧
+
💬
+
🔴
+
+
+
+ 登录即代表同意 服务条款 和 隐私政策 +
+
+
+
+
+
+ + +
+
模板D - 活力渐变风 | 参考:小红书/抖音风格,年轻时尚
+
+
+
+
+
+
+
👋 你好,欢迎
+

可乐Web

+

探索工具、学习、创作的世界

+
+
+
+
+
+ + +
+ +
+ + + + + +
+ + + +
更多登录方式
+
+
+
🐧
+ QQ +
+
+
💬
+ 微信 +
+
+
🔴
+ 微博 +
+
+
+ 登录即表示同意 用户协议 和 隐私政策 +
+
+
+
+
+
+
+ + + + + + + diff --git a/public/icons-variants/icon-c-letter.svg b/public/icons-variants/icon-c-letter.svg new file mode 100644 index 0000000..eb830ae --- /dev/null +++ b/public/icons-variants/icon-c-letter.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons-variants/icon-colabubbles.svg b/public/icons-variants/icon-colabubbles.svg new file mode 100644 index 0000000..327e537 --- /dev/null +++ b/public/icons-variants/icon-colabubbles.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons-variants/icon-hextool.svg b/public/icons-variants/icon-hextool.svg new file mode 100644 index 0000000..c6c326a --- /dev/null +++ b/public/icons-variants/icon-hextool.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons-variants/icon-toolbox.svg b/public/icons-variants/icon-toolbox.svg new file mode 100644 index 0000000..b1a9c3e --- /dev/null +++ b/public/icons-variants/icon-toolbox.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons-variants/preview.html b/public/icons-variants/preview.html new file mode 100644 index 0000000..c4559cf --- /dev/null +++ b/public/icons-variants/preview.html @@ -0,0 +1,348 @@ + + + + + + 图标模板预览 - 可乐项目 + + + + +

可乐项目 - 图标模板预览

+

点击选择你喜欢的图标设计,然后应用为项目 Favicon

+ + +
+

当前图标

+ 当前图标 +
+ + +
+
+ C字母图标 +

字母 C 标识

+

圆润加粗的"C"字母,搭配气泡装饰,呼应可乐品牌。橙红到深红的渐变色调,温暖而有辨识度。

+
+ 品牌辨识 + 极简 + 可乐色调 +
+
+ +
+ 工具箱图标 +

等距工具箱

+

3D等距视角的彩色工具箱,珊瑚色/青色/紫色多面体设计,展现平台多工具属性,现代科技感。

+
+ 3D等距 + 多色 + 工具平台 +
+
+ +
+ 气泡可乐图标 +

抽象气泡杯

+

深色背景上的极简可乐杯,液体中冒出的气泡传递活力感。暗色系设计,科技感十足。

+
+ 暗色系 + 抽象 + 活力 +
+
+ +
+ 六边形工具图标 +

六边形齿轮

+

六边形底座搭配齿轮符号,紫蓝渐变光效。传达精密工具与技术平台的气质,科技前沿感。

+
+ 科技感 + 几何 + 工具符号 +
+
+
+ + +
+

不同尺寸预览

+ +
+ + +
+

设计特性对比

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
图标风格配色适用场景辨识度
字母 C扁平极简暖色系(橙红→深红)品牌一致性最强,适合正式场合⭐⭐⭐⭐⭐
等距工具箱3D 等距多色(珊瑚+青+紫)展现工具多样性,视觉冲击力强⭐⭐⭐⭐
气泡可乐杯暗色抽象暗色系(深棕+焦糖)暗色主题搭配最佳,独特有记忆点⭐⭐⭐⭐
六边形齿轮科技几何冷色系(紫→蓝)技术平台定位,专业现代⭐⭐⭐⭐
+
+ + + + + + \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index d07edad..2ffbad2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -140,6 +140,7 @@ const App: React.FC = () => { return ( <> {!isMobile && } + {isMobile && } }> : } /> diff --git a/src/components/MobileBottomNav/MobileBottomNav.css b/src/components/MobileBottomNav/MobileBottomNav.css new file mode 100644 index 0000000..47155e8 --- /dev/null +++ b/src/components/MobileBottomNav/MobileBottomNav.css @@ -0,0 +1,124 @@ +/* 移动端底部导航栏 */ +.mobile-bottom-nav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 1000; + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-top: 1px solid rgba(0, 0, 0, 0.06); + box-shadow: 0 -1px 12px rgba(0, 0, 0, 0.08); + padding-bottom: env(safe-area-inset-bottom, 0); +} + +.mobile-bottom-nav-inner { + display: flex; + align-items: center; + justify-content: space-around; + height: 56px; + max-width: 500px; + margin: 0 auto; + padding: 0 4px; +} + +.mobile-nav-tab { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + cursor: pointer; + padding: 6px 0; + -webkit-tap-highlight-color: transparent; + user-select: none; + transition: all 0.2s ease; +} + +.mobile-nav-tab:active { + transform: scale(0.92); +} + +.mobile-nav-tab-icon { + font-size: 22px; + color: #999; + transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + position: relative; +} + +.mobile-nav-tab.active .mobile-nav-tab-icon { + color: #667eea; + transform: scale(1.15) translateY(-1px); +} + +/* 选中状态的活跃指示点 */ +.mobile-nav-tab.active .mobile-nav-tab-icon::after { + content: ''; + position: absolute; + bottom: -4px; + left: 50%; + transform: translateX(-50%); + width: 4px; + height: 4px; + background: #667eea; + border-radius: 50%; + animation: dotPulse 0.3s ease-out; +} + +@keyframes dotPulse { + 0% { + transform: translateX(-50%) scale(0); + opacity: 0; + } + 60% { + transform: translateX(-50%) scale(1.3); + } + 100% { + transform: translateX(-50%) scale(1); + opacity: 1; + } +} + +.mobile-nav-tab-label { + font-size: 10px; + font-weight: 500; + color: #999; + transition: all 0.2s ease; + line-height: 1.2; +} + +.mobile-nav-tab.active .mobile-nav-tab-label { + color: #667eea; + font-weight: 600; +} + +/* ============ 暗色模式 ============ */ +[data-theme="dark"] .mobile-bottom-nav { + background: rgba(15, 23, 42, 0.92); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-top: 1px solid rgba(255, 255, 255, 0.06); + box-shadow: 0 -1px 12px rgba(0, 0, 0, 0.3); +} + +[data-theme="dark"] .mobile-nav-tab-icon { + color: #666; +} + +[data-theme="dark"] .mobile-nav-tab.active .mobile-nav-tab-icon { + color: #818cf8; +} + +[data-theme="dark"] .mobile-nav-tab.active .mobile-nav-tab-icon::after { + background: #818cf8; +} + +[data-theme="dark"] .mobile-nav-tab-label { + color: #666; +} + +[data-theme="dark"] .mobile-nav-tab.active .mobile-nav-tab-label { + color: #818cf8; +} diff --git a/src/components/MobileBottomNav/MobileBottomNav.tsx b/src/components/MobileBottomNav/MobileBottomNav.tsx new file mode 100644 index 0000000..a1d3b73 --- /dev/null +++ b/src/components/MobileBottomNav/MobileBottomNav.tsx @@ -0,0 +1,67 @@ +import React, { useState, useEffect } from "react"; +import { useNavigate, useLocation } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { + HomeOutlined, + ApiOutlined, + ToolOutlined, + ReadOutlined, + FolderOutlined, + UserOutlined, + SearchOutlined, +} from "@ant-design/icons"; +import "./MobileBottomNav.css"; + +const MobileBottomNav: React.FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const [activeTab, setActiveTab] = useState("home"); + + const tabs = [ + { key: "home", icon: , label: t("nav.home"), path: "/" }, + { key: "api", icon: , label: t("nav.tools"), path: "/api" }, + { key: "utility", icon: , label: t("nav.utility"), path: "/utility" }, + { key: "articles", icon: , label: t("nav.learn"), path: "/learn" }, + { key: "profile", icon: , label: t("nav.bug"), path: "/bug" }, + ]; + + useEffect(() => { + const path = location.pathname; + if (path === "/") { + setActiveTab("home"); + } else if (path.startsWith("/api")) { + setActiveTab("api"); + } else if (path.startsWith("/utility")) { + setActiveTab("utility"); + } else if (path.startsWith("/learn")) { + setActiveTab("articles"); + } else if (path.startsWith("/bug")) { + setActiveTab("profile"); + } + }, [location.pathname]); + + const handleTabClick = (tab: typeof tabs[0]) => { + setActiveTab(tab.key); + navigate(tab.path); + }; + + return ( +
+
+ {tabs.map((tab) => ( +
handleTabClick(tab)} + > +
{tab.icon}
+
{tab.label}
+
+ ))} +
+
+ ); +}; + +export default MobileBottomNav; diff --git a/src/components/MobileTopNav/MobileTopNav.css b/src/components/MobileTopNav/MobileTopNav.css new file mode 100644 index 0000000..3d60024 --- /dev/null +++ b/src/components/MobileTopNav/MobileTopNav.css @@ -0,0 +1,136 @@ +/* 移动端顶部导航栏 - 参考主流 App 风格(微信/微博/抖音顶部栏) */ +.mobile-top-nav { + position: sticky; + top: 0; + left: 0; + right: 0; + z-index: 999; + background: rgba(255, 255, 255, 0.88); + backdrop-filter: saturate(180%) blur(20px); + -webkit-backdrop-filter: saturate(180%) blur(20px); + border-bottom: 1px solid rgba(0, 0, 0, 0.05); +} + +.mobile-top-nav-inner { + display: flex; + align-items: center; + justify-content: space-between; + height: 48px; + padding: 0 16px; + max-width: 100%; +} + +/* Logo 区域 */ +.mobile-top-nav-logo { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + user-select: none; + flex-shrink: 0; +} + +.mobile-top-nav-logo-text { + font-size: 20px; + font-weight: 800; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: -0.5px; + line-height: 1; +} + +/* 右侧操作区 */ +.mobile-top-nav-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +/* 主题切换按钮 */ +.mobile-top-nav-theme { + display: flex; + align-items: center; + padding: 0 2px; +} + +/* 图标按钮(搜索、消息) */ +.mobile-top-nav-icon-btn { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + user-select: none; + transition: background-color 0.2s ease; + font-size: 18px; + color: var(--text-secondary); +} + +.mobile-top-nav-icon-btn:active { + background-color: rgba(0, 0, 0, 0.05); + transform: scale(0.92); +} + +/* 消息 Badge 覆盖 */ +.mobile-top-nav-icon-btn .ant-badge { + display: flex; + align-items: center; + justify-content: center; +} + +.mobile-top-nav-icon-btn .ant-badge .anticon { + font-size: 18px; + color: var(--text-secondary); +} + +/* 用户头像 */ +.mobile-top-nav-avatar { + display: flex; + align-items: center; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + margin-left: 2px; +} + +.mobile-top-nav-avatar:active { + transform: scale(0.92); +} + +.mobile-top-nav-avatar-img { + transition: all 0.2s ease; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); +} + +.mobile-top-nav-avatar-login { + background: var(--primary-gradient); + color: #fff; +} + +/* ============ 暗色模式 ============ */ +[data-theme="dark"] .mobile-top-nav { + background: rgba(15, 23, 42, 0.88); + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +[data-theme="dark"] .mobile-top-nav-icon-btn { + color: var(--text-secondary); +} + +[data-theme="dark"] .mobile-top-nav-icon-btn:active { + background-color: rgba(255, 255, 255, 0.08); +} + +[data-theme="dark"] .mobile-top-nav-icon-btn .anticon { + color: var(--text-secondary); +} + +[data-theme="dark"] .mobile-top-nav-avatar-img { + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); +} diff --git a/src/components/MobileTopNav/MobileTopNav.tsx b/src/components/MobileTopNav/MobileTopNav.tsx new file mode 100644 index 0000000..23c8d81 --- /dev/null +++ b/src/components/MobileTopNav/MobileTopNav.tsx @@ -0,0 +1,132 @@ +import React, { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useSelector } from "react-redux"; +import { + SearchOutlined, + MessageOutlined, + UserOutlined, +} from "@ant-design/icons"; +import { Avatar, Badge } from "antd"; +import { + selectIsLogin, + selectUser, +} from "@/features/login"; +import { api_request } from "@/utils/request"; +import { useLoginModal } from "@/contexts/LoginModalContext"; +import ThemeToggle from "../ThemeToggle/ThemeToggle"; +import LanguageSwitcher from "../LanguageSwitcher/LanguageSwitcher"; +import "./MobileTopNav.css"; + +const MobileTopNav: React.FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const isLogin = useSelector(selectIsLogin); + const user = useSelector(selectUser); + const { openLoginModal } = useLoginModal(); + const [unreadCount, setUnreadCount] = useState(0); + + useEffect(() => { + const fetchUnreadCount = async () => { + const token = localStorage.getItem("access_token"); + if (!token) { + setUnreadCount(0); + return; + } + try { + const res = await api_request.message.getUnreadCount(); + const data = (res as any)?.data || res; + if (data) { + const total = + (data.reply || 0) + + (data.at_me || 0) + + (data.like || 0) + + (data.system || 0) + + (data.chat || 0); + setUnreadCount(total); + } + } catch {} + }; + + fetchUnreadCount(); + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + fetchUnreadCount(); + } + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [isLogin]); + + return ( +
+
+ {/* 左侧 Logo */} +
navigate("/")} + > + 可乐 +
+ + {/* 右侧操作区 */} +
+
+ +
+ +
navigate("/search")} + > + +
+ +
{ + if (!isLogin) { + openLoginModal(); + return; + } + navigate("/messages"); + }} + > + + + +
+ +
{ + if (!isLogin) { + openLoginModal(); + return; + } + navigate("/user"); + }} + > + {isLogin ? ( + } + className="mobile-top-nav-avatar-img" + /> + ) : ( + } + className="mobile-top-nav-avatar-img mobile-top-nav-avatar-login" + /> + )} +
+
+
+
+ ); +}; + +export default MobileTopNav; diff --git a/src/components/Navbar/Navbar.css b/src/components/Navbar/Navbar.css index 822c4eb..92dc673 100644 --- a/src/components/Navbar/Navbar.css +++ b/src/components/Navbar/Navbar.css @@ -38,6 +38,28 @@ transition: all 0.3s ease; } +/* 紧凑搜索图标按钮(中等屏幕使用) */ +.navbar-compact-search-btn { + width: 36px; + height: 36px; + border-radius: 8px; + background: rgba(102, 126, 234, 0.1); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.3s ease; + font-size: 16px; + color: var(--text-muted); + margin: 0 auto; +} + +.navbar-compact-search-btn:hover { + background: rgba(102, 126, 234, 0.18); + color: var(--primary-color); + transform: scale(1.08); +} + .navbar-search-input:hover .ant-input { border-color: transparent !important; box-shadow: none !important; @@ -786,6 +808,17 @@ border-color: var(--border-color); } +/* 紧凑搜索按钮暗色模式 */ +[data-theme="dark"] .navbar-compact-search-btn { + background: rgba(129, 140, 248, 0.12); + color: var(--primary-color); +} + +[data-theme="dark"] .navbar-compact-search-btn:hover { + background: rgba(129, 140, 248, 0.22); + color: var(--primary-color); +} + [data-theme="dark"] .navbar-search-input .ant-input { background: transparent !important; border-color: transparent !important; diff --git a/src/components/Navbar/Navbar.tsx b/src/components/Navbar/Navbar.tsx index d6db7db..f858113 100644 --- a/src/components/Navbar/Navbar.tsx +++ b/src/components/Navbar/Navbar.tsx @@ -60,6 +60,7 @@ const Navbar: React.FC = () => { const [currentMenuItem, setCurrentMenuItem] = useState("home"); const [searchKeyword, setSearchKeyword] = useState(""); + const [windowWidth, setWindowWidth] = useState(window.innerWidth); const [replyCount, setReplyCount] = useState(0); const [atCount, setAtCount] = useState(0); @@ -70,6 +71,16 @@ const Navbar: React.FC = () => { const navigation = useNavigate(); const location = useLocation(); + // 窗口宽度监听,用于搜索框响应式 + useEffect(() => { + const handleResize = () => setWindowWidth(window.innerWidth); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + // 中等屏幕(992px~1280px)时隐藏搜索输入框,显示搜索图标按钮 + const isCompactSearch = windowWidth > 992 && windowWidth <= 1280; + useEffect(() => { const params = new URLSearchParams(location.search); const q = params.get("q") || ""; @@ -472,21 +483,31 @@ const Navbar: React.FC = () => {
-
navigation("/search")}> - } - value={searchKeyword} - onChange={(e) => setSearchKeyword(e.target.value)} - onPressEnter={() => { - const q = searchKeyword.trim(); - if (q) navigation(`/search?q=${encodeURIComponent(q)}`); - }} - onClick={(e) => e.stopPropagation()} - allowClear - /> -
+ {isCompactSearch ? ( +
navigation("/search")} + title={t("nav.searchPlaceholder")} + > + +
+ ) : ( +
navigation("/search")}> + } + value={searchKeyword} + onChange={(e) => setSearchKeyword(e.target.value)} + onPressEnter={() => { + const q = searchKeyword.trim(); + if (q) navigation(`/search?q=${encodeURIComponent(q)}`); + }} + onClick={(e) => e.stopPropagation()} + allowClear + /> +
+ )}
diff --git a/src/contexts/LoginModalContext.tsx b/src/contexts/LoginModalContext.tsx index e4d3938..3dd4b46 100644 --- a/src/contexts/LoginModalContext.tsx +++ b/src/contexts/LoginModalContext.tsx @@ -73,7 +73,7 @@ export const LoginModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch const [qrToken, setQrToken] = useState(null); const [qrStatus, setQrStatus] = useState("waiting"); const [qrUsername, setQrUsername] = useState(""); - const pollingRef = useRef(null); + const wsRef = useRef(null); const openLoginModal = useCallback(() => { setOpen(true); @@ -82,12 +82,60 @@ export const LoginModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch const closeLoginModal = useCallback(() => { setOpen(false); - if (pollingRef.current) { - clearInterval(pollingRef.current); - pollingRef.current = null; + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; } }, []); + const handleAuthData = (auth: Record) => { + if (auth.user) localStorage.setItem("user", JSON.stringify(auth.user)); + if (auth.access) localStorage.setItem("access_token", auth.access); + if (auth.refresh) localStorage.setItem("refresh_token", auth.refresh); + if (auth.token_type) localStorage.setItem("token_type", auth.token_type); + if (auth.expires_at_timestamp) { + localStorage.setItem("expires_at_timestamp", String(auth.expires_at_timestamp)); + } + setQrStatus("confirmed"); + window.location.reload(); + }; + + const connectWs = (token: string) => { + if (wsRef.current) { + wsRef.current.close(); + } + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${window.location.host}/ws/qr-status/${token}/`; + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.status) setQrStatus(data.status); + if (data.username) setQrUsername(data.username); + if (data.status === "confirmed" && data.auth) { + ws.close(); + handleAuthData(data.auth); + } + if (data.status === "expired") { + ws.close(); + fetchQrToken(); + } + } catch { + // ignore malformed messages + } + }; + + ws.onerror = () => { + ws.close(); + }; + + ws.onclose = () => { + if (wsRef.current === ws) wsRef.current = null; + }; + }; + const fetchQrToken = async () => { try { const res = await api_request.user.getQrToken(); @@ -95,51 +143,18 @@ export const LoginModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch setQrToken(res.data.token); setQrStatus("waiting"); setQrUsername(""); - startPolling(res.data.token); + connectWs(res.data.token); } } catch { setQrToken(null); } }; - const startPolling = (token: string) => { - if (pollingRef.current) { - clearInterval(pollingRef.current); - } - pollingRef.current = setInterval(async () => { - try { - const res = await api_request.user.getQrStatus(token); - if (res.data) { - setQrStatus(res.data.status); - setQrUsername(res.data.username || ""); - if (res.data.status === "confirmed" && res.data.auth) { - if (pollingRef.current) clearInterval(pollingRef.current); - const auth = res.data.auth; - if (auth.user) localStorage.setItem("user", JSON.stringify(auth.user)); - if (auth.access) localStorage.setItem("access_token", auth.access); - if (auth.refresh) localStorage.setItem("refresh_token", auth.refresh); - if (auth.token_type) localStorage.setItem("token_type", auth.token_type); - if (auth.expires_at_timestamp) { - localStorage.setItem("expires_at_timestamp", String(auth.expires_at_timestamp)); - } - setQrStatus("confirmed"); - window.location.reload(); - } - if (res.data.status === "expired") { - if (pollingRef.current) clearInterval(pollingRef.current); - fetchQrToken(); - } - } - } catch { - // ignore polling errors - } - }, 2000); - }; - useEffect(() => { return () => { - if (pollingRef.current) { - clearInterval(pollingRef.current); + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; } }; }, []); diff --git a/src/pages/LoginMobile/login-templates-preview.html b/src/pages/LoginMobile/login-templates-preview.html new file mode 100644 index 0000000..60cbb74 --- /dev/null +++ b/src/pages/LoginMobile/login-templates-preview.html @@ -0,0 +1,1284 @@ + + + + + +可乐Web - 移动端登录/注册模板预览 + + + + + + + + + + + +
+
+
+

模板 A · 渐变卡片

+

渐变背景 + 毛玻璃卡片,主流社交App风格

+ 渐变色彩 + 圆角卡片 +
+
+
+
+
+ 9:41 +
+ + + +
+
+ +
+
+ + 可乐Web +
+
欢迎回来
+
登录你的账户,继续探索
+
+ +
+
+ + +
+ + +
+
+ +
+ 👤 + +
+
+
+ +
+ 🔒 + + 👁 +
+
+ + +
+ + + + +
其他方式
+ + +
+ 登录即代表同意 用户协议 和 隐私政策 +
+
+
+
+
+
+ + +
+
+
+

模板 B · 极简清爽

+

大留白 + 线条感,类似 Linear / Notion 风格

+ 极简设计 + 清爽留白 +
+
+
+
+
+ 9:41 +
+ + +
+
+ +
+
←
+ +
+

创建账户

+

加入可乐Web,开始你的探索之旅

+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+ + + +
+ 已有账户? 立即登录 +
+
+ + + + + + +
+ 登录即代表同意 用户协议 和 隐私政策 +
+
+
+
+
+
+ + +
+
+
+

模板 C · 玻璃拟态

+

深色渐变 + 毛玻璃效果,科技感十足

+ 深色主题 + 玻璃拟态 +
+
+
+
+
+ 9:41 +
+ + + +
+
+ +
+
+
🥤
+
可乐Web
+
你的全能在线工具平台
+
+ +
+
+ + +
+ + +
+
+ + +
+
+ + +
+ + + +
OR
+ + +
+ + + +
+ +
+ 登录即代表同意 用户协议 和 隐私政策 +
+
+
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index b07f48e..1340f8e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,9 +15,8 @@ export default defineConfig({ ], proxy: { '/api': { - target: 'http://127.0.0.1:8000', + target: 'http://127.0.0.1:8002', changeOrigin: true, - rewrite: (path) => path.replace(/^\/api/, ''), bypass: (req) => { if (req.headers.accept?.includes('text/html')) { return req.url; @@ -25,16 +24,16 @@ export default defineConfig({ }, }, '/ws': { - target: 'ws://127.0.0.1:8000', + target: 'ws://127.0.0.1:8002', ws: true, rewrite: (path) => path.replace(/^\/ws/, ''), }, '/media/': { - target: 'http://127.0.0.1:8000', + target: 'http://127.0.0.1:8002', changeOrigin: true, }, '/user': { - target: 'http://127.0.0.1:8000', + target: 'http://127.0.0.1:8002', changeOrigin: true, bypass: (req) => { if (req.headers.accept?.includes('text/html')) {