);
@@ -273,24 +381,26 @@ export default function HomePage() {
width={260}
collapsedWidth={72}
theme="light"
- className="!min-h-0 !overflow-hidden !bg-[var(--ds-bg-sider)] !border-r !border-[var(--ds-border)]"
+ className="!min-h-0 !h-full !overflow-hidden !bg-[var(--ds-bg-sider)] !border-r !border-[var(--ds-border)]"
>
-
-

- {!collapsed && (
-
- ChatOne
-
- )}
+
+
+

+ {!collapsed && (
+
+ ChatOne
+
+ )}
+
+ {sidebarContent}
- {sidebarContent}
)}
@@ -500,7 +610,16 @@ export default function HomePage() {
onClose={() => setMobileSidebarOpen(false)}
open={isMobile && mobileSidebarOpen}
width={280}
- styles={{ body: { padding: 0, background: "var(--ds-bg-sider)" } }}
+ styles={{
+ body: {
+ padding: 0,
+ background: "var(--ds-bg-sider)",
+ display: "flex",
+ flexDirection: "column",
+ height: "100%",
+ minHeight: 0,
+ },
+ }}
>
{sidebarContent}
diff --git a/src/pages/login.tsx b/src/pages/login.tsx
new file mode 100644
index 0000000..4790c9d
--- /dev/null
+++ b/src/pages/login.tsx
@@ -0,0 +1,210 @@
+import { Button, Input, Typography, message } from "antd";
+import { useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import {
+ ACCESS_TOKEN_KEY,
+ persistTokens,
+ persistUser,
+ sendAuthSmsCode,
+ smsLogin,
+} from "../api/auth";
+import { tw } from "../utils/tw";
+
+/** 发送验证码成功后,按钮冷却秒数 */
+const SMS_RESEND_COOLDOWN_SEC = 60;
+
+/** 短信验证码登录页:校验本地 token、发送验证码、提交登录 */
+export default function LoginPage() {
+ const navigate = useNavigate();
+ const [phone, setPhone] = useState("");
+ const [code, setCode] = useState("");
+ const [sendingSms, setSendingSms] = useState(false);
+ const [smsCooldown, setSmsCooldown] = useState(0);
+ const [loggingIn, setLoggingIn] = useState(false);
+ const [msgApi, contextHolder] = message.useMessage();
+
+ /** 已登录则直接进入首页,避免重复停留在登录页 */
+ useEffect(() => {
+ const accessToken = window.localStorage.getItem(ACCESS_TOKEN_KEY);
+ if (accessToken) {
+ navigate("/", { replace: true });
+ }
+ }, [navigate]);
+
+ const smsCountdownActive = smsCooldown > 0;
+ /** 验证码发送冷却:每秒递减,到 0 后允许再次发送 */
+ useEffect(() => {
+ if (!smsCountdownActive) return undefined;
+ const id = window.setInterval(() => {
+ setSmsCooldown((s) => (s <= 1 ? 0 : s - 1));
+ }, 1000);
+ return () => window.clearInterval(id);
+ }, [smsCountdownActive]);
+
+ /**
+ * 提交手机号与验证码,调用登录接口;成功则持久化 token 并跳转首页。
+ */
+ const onLogin = async () => {
+ const formattedPhone = formatPhoneForApi(phone);
+ if (!formattedPhone) {
+ msgApi.warning("请输入正确的手机号");
+ return;
+ }
+ const trimmedCode = code.trim();
+ if (!trimmedCode) {
+ msgApi.warning("请输入验证码");
+ return;
+ }
+ if (loggingIn) return;
+ setLoggingIn(true);
+ try {
+ const data = await smsLogin(formattedPhone, trimmedCode);
+ persistTokens(data.accessToken, data.refreshToken);
+ persistUser(data.user);
+ msgApi.success("登录成功");
+ navigate("/", { replace: true });
+ } catch (error) {
+ const text = error instanceof Error ? error.message : "登录失败";
+ msgApi.error(text || "登录失败");
+ } finally {
+ setLoggingIn(false);
+ }
+ };
+
+ /**
+ * 将用户输入规范为后端要求的国际格式(如 `+8613xxxxxxxx`)。
+ * @returns 无法识别时返回 `null`
+ */
+ function formatPhoneForApi(input: string): string | null {
+ const cleaned = input.trim().replace(/\s+/g, "");
+ if (!cleaned) return null;
+ if (cleaned.startsWith("+")) return cleaned;
+ if (/^86\d{11}$/.test(cleaned)) return `+${cleaned}`;
+ if (/^1\d{10}$/.test(cleaned)) return `+86${cleaned}`;
+ return null;
+ }
+
+ /**
+ * 请求发送短信验证码;成功后启动冷却,若接口返回 `testCode` 则自动填入(联调环境)。
+ */
+ const onSendSmsCode = async () => {
+ const formattedPhone = formatPhoneForApi(phone);
+ if (!formattedPhone) {
+ msgApi.warning("请输入正确的手机号");
+ return;
+ }
+ if (sendingSms || smsCooldown > 0) return;
+ setSendingSms(true);
+ try {
+ const { testCode } = await sendAuthSmsCode(formattedPhone);
+ if (testCode) {
+ setCode(testCode);
+ }
+ setSmsCooldown(SMS_RESEND_COOLDOWN_SEC);
+ msgApi.success("验证码已发送");
+ } catch (error) {
+ const text = error instanceof Error ? error.message : "验证码发送失败";
+ msgApi.error(text || "验证码发送失败");
+ } finally {
+ setSendingSms(false);
+ }
+ };
+
+ /** 手机号、验证码两个输入框共用的外观 class */
+ const loginFieldClass = tw(
+ "h-[48px] !rounded-[999px] border-gray-200",
+ "!bg-gray-50 !text-[14px]",
+ );
+
+ return (
+
+ {contextHolder}
+
+
+

+
+ ChatOne
+
+
+
+
+
setPhone(e.target.value)}
+ className={loginFieldClass}
+ prefix={
+86}
+ />
+
+
setCode(e.target.value)}
+ className={loginFieldClass}
+ suffix={
+
+
+
+
+ }
+ />
+
+
+ 注册登录即代表已阅读并同意我们的{" "}
+
+ 用户协议
+ {" "}
+ 与{" "}
+
+ 隐私政策
+
+ ,未注册的手机号将自动注册
+
+
+
+
+
+
+ );
+}
diff --git a/src/utils/tw.ts b/src/utils/tw.ts
new file mode 100644
index 0000000..f8f0093
--- /dev/null
+++ b/src/utils/tw.ts
@@ -0,0 +1,6 @@
+/**
+ * 拼接 Tailwind / 任意 class 片段。用于把长 className 拆成多行,避免单行过长。
+ */
+export function tw(...parts: string[]): string {
+ return parts.filter(Boolean).join(" ");
+}