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风格,科技感十足
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
或使用第三方账号
+
+
+
+
+
+
+
+
+
+
+
模板C - 玻璃拟态风 | 参考:iOS/Apple风格,高级感拉满
+
+
+
+
+
+
模板D - 活力渐变风 | 参考:小红书/抖音风格,年轻时尚
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
更多登录方式
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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"字母,搭配气泡装饰,呼应可乐品牌。橙红到深红的渐变色调,温暖而有辨识度。
+
+ 品牌辨识
+ 极简
+ 可乐色调
+
+
+
+
+

+
等距工具箱
+
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风格
+
渐变色彩
+
圆角卡片
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
其他方式
+
+
+
+
+
+
+
+
+
+
+
+
+
+
模板 B · 极简清爽
+
大留白 + 线条感,类似 Linear / Notion 风格
+
极简设计
+
清爽留白
+
+
+
+
+
+
+
+
←
+
+
+
创建账户
+
加入可乐Web,开始你的探索之旅
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
模板 C · 玻璃拟态
+
深色渐变 + 毛玻璃效果,科技感十足
+
深色主题
+
玻璃拟态
+
+
+
+
+
+
+
+
+
🥤
+
可乐Web
+
你的全能在线工具平台
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
OR
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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')) {