From 4773f43bac314bcddd34f48454eef8480667b267 Mon Sep 17 00:00:00 2001 From: zephyr-sh Date: Sun, 2 Mar 2025 09:47:20 +0800 Subject: [PATCH 01/25] [A] Add dashboard --- docusaurus.config.js | 18 +- package.json | 7 +- src/components/AuthModal.js | 124 + .../dashboard/DashboardApiKey/index.js | 50 + .../dashboard/DashboardApiUsage/index.js | 48 + .../dashboard/DashboardMyComments/index.js | 139 ++ .../dashboard/DashboardMyInfo/index.js | 426 ++++ src/components/forms/ForgotPasswordForm.js | 40 + src/components/forms/LoginForm.js | 72 + src/components/forms/RegisterForm.js | 45 + src/context/AuthContext.js | 61 + src/hooks/useAuthHandler.js | 46 + src/pages/dashboard.js | 257 +++ src/theme/Navbar/ColorModeToggle/index.js | 22 + .../Navbar/ColorModeToggle/styles.module.css | 3 + src/theme/Navbar/Content/index.js | 149 ++ src/theme/Navbar/Content/styles.module.css | 15 + src/theme/Navbar/Layout/index.js | 52 + src/theme/Navbar/Layout/styles.module.css | 7 + src/theme/Navbar/Logo/index.js | 11 + .../Navbar/MobileSidebar/Header/index.js | 31 + .../Navbar/MobileSidebar/Layout/index.js | 22 + .../Navbar/MobileSidebar/PrimaryMenu/index.js | 27 + .../MobileSidebar/SecondaryMenu/index.js | 30 + .../Navbar/MobileSidebar/Toggle/index.js | 22 + src/theme/Navbar/MobileSidebar/index.js | 23 + src/theme/Navbar/Search/index.js | 10 + src/theme/Navbar/Search/styles.module.css | 21 + src/theme/Navbar/index.js | 10 + src/theme/Root/index.js | 6 + src/utils/mockApi.js | 223 ++ yarn.lock | 2027 ++++++++++++++++- 32 files changed, 3969 insertions(+), 75 deletions(-) create mode 100644 src/components/AuthModal.js create mode 100644 src/components/dashboard/DashboardApiKey/index.js create mode 100644 src/components/dashboard/DashboardApiUsage/index.js create mode 100644 src/components/dashboard/DashboardMyComments/index.js create mode 100644 src/components/dashboard/DashboardMyInfo/index.js create mode 100644 src/components/forms/ForgotPasswordForm.js create mode 100644 src/components/forms/LoginForm.js create mode 100644 src/components/forms/RegisterForm.js create mode 100644 src/context/AuthContext.js create mode 100644 src/hooks/useAuthHandler.js create mode 100644 src/pages/dashboard.js create mode 100644 src/theme/Navbar/ColorModeToggle/index.js create mode 100644 src/theme/Navbar/ColorModeToggle/styles.module.css create mode 100644 src/theme/Navbar/Content/index.js create mode 100644 src/theme/Navbar/Content/styles.module.css create mode 100644 src/theme/Navbar/Layout/index.js create mode 100644 src/theme/Navbar/Layout/styles.module.css create mode 100644 src/theme/Navbar/Logo/index.js create mode 100644 src/theme/Navbar/MobileSidebar/Header/index.js create mode 100644 src/theme/Navbar/MobileSidebar/Layout/index.js create mode 100644 src/theme/Navbar/MobileSidebar/PrimaryMenu/index.js create mode 100644 src/theme/Navbar/MobileSidebar/SecondaryMenu/index.js create mode 100644 src/theme/Navbar/MobileSidebar/Toggle/index.js create mode 100644 src/theme/Navbar/MobileSidebar/index.js create mode 100644 src/theme/Navbar/Search/index.js create mode 100644 src/theme/Navbar/Search/styles.module.css create mode 100644 src/theme/Navbar/index.js create mode 100644 src/theme/Root/index.js create mode 100644 src/utils/mockApi.js diff --git a/docusaurus.config.js b/docusaurus.config.js index a4f33679c52..b3d22b9d587 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -132,20 +132,10 @@ const config = { type: 'localeDropdown', position: 'right', }, - { - label: 'GitHub', - position: 'right', - href: 'https://github.com/DocsaidLab', - }, - { - label: '支持我們', - position: 'right', - href: 'https://buymeacoffee.com/docsaid', - }, { label: '關於我們', href: '/aboutus', - position: 'right', + position: 'left', }, ], @@ -196,11 +186,7 @@ const config = { { label: '工作日誌', href: '/worklog', - }, - { - label: '支持我們', - href: 'https://buymeacoffee.com/docsaid', - }, + } ], copyright: `Copyright © ${new Date().getFullYear()} DOCSAID.`, }, diff --git a/package.json b/package.json index c69ffd06477..3d41d81b576 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,9 @@ }, "dependencies": { "@algolia/client-search": "^4.9.1", + "@ant-design/charts": "^2.2.6", + "@ant-design/pro-card": "^2.9.6", + "@ant-design/pro-layout": "^7.22.3", "@docsearch/react": "^3.6.0", "@docusaurus/core": "^3.7.0", "@docusaurus/faster": "^3.7.0", @@ -32,15 +35,17 @@ "antd": "^5.22.2", "apexcharts": "^4.2.0", "axios": "^1.6.8", - "chart.js": "4.4.6", + "chart.js": "4.4.8", "clsx": "^2.0.0", "d3": "^7.9.0", "fft-js": "^0.0.12", "fourier-transform": "^1.1.2", "framer-motion": "^12.4.7", "gh-pages": "^6.1.0", + "hull.js": "^1.0.6", "i18next": "^23.15.1", "mathjs": "^14.0.1", + "moment": "^2.30.1", "prism-react-renderer": "^2.3.0", "react": "^19.0.0", "react-apexcharts": "^1.7.0", diff --git a/src/components/AuthModal.js b/src/components/AuthModal.js new file mode 100644 index 00000000000..e60e007b626 --- /dev/null +++ b/src/components/AuthModal.js @@ -0,0 +1,124 @@ +import { FacebookOutlined, GoogleOutlined } from "@ant-design/icons"; +import { Button, Divider, Modal, Space, Tabs, Typography } from "antd"; +import React, { useState } from "react"; +import useAuthHandler from "../hooks/useAuthHandler"; +import ForgotPasswordForm from "./forms/ForgotPasswordForm"; +import LoginForm from "./forms/LoginForm"; +import RegisterForm from "./forms/RegisterForm"; + +export default function AuthModal({ visible, onCancel }) { + // 用同一個 hook => 只管理登入 / 註冊 / 社群登入的 loading + const { login, register, socialLogin, loading } = useAuthHandler(); + const [activeKey, setActiveKey] = useState("login"); + + // 這裡額外用一個 state 記錄當前模式 => "login" / "register" / "forgotPassword" + const [mode, setMode] = useState("login"); + + const goToForgotPassword = () => setMode("forgotPassword"); + const goToLogin = () => setMode("login"); + const goToRegister = () => setMode("register"); + + // 社群登入按鈕 + const socialButtons = ( + + + + + ); + + // 對應登入 / 註冊 UI + const renderLoginContent = () => ( + <> + + 或使用以下帳號登入 + {socialButtons} + + ); + + const renderRegisterContent = () => ( + <> + + 或使用以下帳號註冊 / 登入 + {socialButtons} + + ); + + // 忘記密碼畫面只需要一個 Email => 發送重設信 + const renderForgotPasswordContent = () => ( + <> + + + + 回到登入 + + + ); + + const renderContent = () => { + switch (mode) { + case "login": + return renderLoginContent(); + case "register": + return renderRegisterContent(); + case "forgotPassword": + return renderForgotPasswordContent(); + default: + return null; + } + }; + + return ( + + {/* 這裡若想維持 Tabs,也可以把三種狀態都做成 Tabs */} + {/* 不過常見做法是 forgotPassword 就脫離 Tabs,獨立顯示 */} + + {mode === "forgotPassword" ? ( + renderContent() + ) : ( + setMode(key)} + items={[ + { + key: "login", + label: "登入", + children: renderLoginContent() + }, + { + key: "register", + label: "註冊", + children: renderRegisterContent() + } + ]} + /> + )} + + ); +} diff --git a/src/components/dashboard/DashboardApiKey/index.js b/src/components/dashboard/DashboardApiKey/index.js new file mode 100644 index 00000000000..da2090eed15 --- /dev/null +++ b/src/components/dashboard/DashboardApiKey/index.js @@ -0,0 +1,50 @@ +import { Button, Card, message } from "antd"; +import React, { useEffect, useState } from "react"; +import { getMyApiKeyApi, regenerateApiKeyApi } from "../../../utils/mockApi"; + +export default function DashboardApiKey() { + const [loading, setLoading] = useState(false); + const [apiKey, setApiKey] = useState(""); + + const fetchApiKey = async () => { + setLoading(true); + try { + const key = await getMyApiKeyApi(); + setApiKey(key); + } catch (err) { + message.error(err.message || "取得 API Key 失敗"); + } finally { + setLoading(false); + } + }; + + const regenerateKey = async () => { + setLoading(true); + try { + const newKey = await regenerateApiKeyApi(); + setApiKey(newKey); + message.success("已重新生成 API Key"); + } catch (err) { + message.error(err.message || "重新生成失敗"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchApiKey(); + }, []); + + return ( + +

我的 API Key

+

目前的 API Key:

+
+        {apiKey || "N/A"}
+      
+ +
+ ); +} diff --git a/src/components/dashboard/DashboardApiUsage/index.js b/src/components/dashboard/DashboardApiUsage/index.js new file mode 100644 index 00000000000..545302f68ec --- /dev/null +++ b/src/components/dashboard/DashboardApiUsage/index.js @@ -0,0 +1,48 @@ +// src/components/dashboard/DashboardApiUsage.jsx +import { message, Spin, Table } from "antd"; +import React, { useEffect, useState } from "react"; +import { getApiUsageApi } from "../../../utils/mockApi"; + +export default function DashboardApiUsage() { + const [loading, setLoading] = useState(false); + const [usageList, setUsageList] = useState([]); + + useEffect(() => { + fetchUsage(); + }, []); + + const fetchUsage = async () => { + setLoading(true); + try { + const data = await getApiUsageApi(); + setUsageList(data); + } catch (err) { + message.error(err.message || "取得 API 使用紀錄失敗"); + } finally { + setLoading(false); + } + }; + + const columns = [ + { title: "呼叫時間", dataIndex: "timestamp", width: 200 }, + { title: "API 路徑", dataIndex: "endpoint" }, + { title: "狀態碼", dataIndex: "statusCode", width: 120 }, + { title: "耗時 (ms)", dataIndex: "latency", width: 120 }, + ]; + + return ( +
+

API 使用紀錄

+ {loading ? ( + + ) : ( + + )} + + ); +} diff --git a/src/components/dashboard/DashboardMyComments/index.js b/src/components/dashboard/DashboardMyComments/index.js new file mode 100644 index 00000000000..5bf0e2c27d0 --- /dev/null +++ b/src/components/dashboard/DashboardMyComments/index.js @@ -0,0 +1,139 @@ +// src/components/dashboard/DashboardMyComments.jsx +import { Button, Form, Input, message, Modal, Table } from "antd"; +import React, { useEffect, useState } from "react"; +import { deleteCommentApi, getMyCommentsApi, updateCommentApi } from "../../../utils/mockApi"; + +export default function DashboardMyComments() { + const [loading, setLoading] = useState(false); + const [comments, setComments] = useState([]); + const [editingComment, setEditingComment] = useState(null); + const [editModalVisible, setEditModalVisible] = useState(false); + + useEffect(() => { + fetchComments(); + }, []); + + const fetchComments = async () => { + setLoading(true); + try { + // 呼叫後端抓此用戶的所有留言 + const data = await getMyCommentsApi(); + setComments(data); + } catch (err) { + message.error(err.message || "取得留言失敗"); + } finally { + setLoading(false); + } + }; + + const handleEdit = (record) => { + setEditingComment({ ...record }); + setEditModalVisible(true); + }; + + const handleDelete = async (id) => { + try { + await deleteCommentApi(id); + message.success("留言已刪除"); + setComments((prev) => prev.filter((c) => c.id !== id)); + } catch (err) { + message.error(err.message || "刪除失敗"); + } + }; + + const handleSaveComment = async (values) => { + try { + await updateCommentApi(values.id, values.content); + message.success("留言已更新"); + setComments((prev) => + prev.map((c) => (c.id === values.id ? { ...c, ...values } : c)) + ); + setEditModalVisible(false); + } catch (err) { + message.error(err.message || "更新失敗"); + } + }; + + const columns = [ + { title: "ID", dataIndex: "id", width: 80 }, + { + title: "留言內容", + dataIndex: "content", + render: (text) => <>{text}, + }, + { + title: "建立日期", + dataIndex: "createdAt", + width: 160, + }, + { + title: "操作", + render: (text, record) => ( + <> + + + + ), + }, + ]; + + return ( +
+

我的留言

+
+ + setEditModalVisible(false)} + onSave={handleSaveComment} + /> + + ); +} + +function EditCommentModal({ visible, onCancel, comment, onSave }) { + const [form] = Form.useForm(); + + useEffect(() => { + if (comment) { + form.setFieldsValue(comment); + } + }, [comment]); + + const onFinish = (values) => { + onSave(values); + }; + + return ( + form.submit()} + > +
+ + + + + +
+ ); +} diff --git a/src/components/dashboard/DashboardMyInfo/index.js b/src/components/dashboard/DashboardMyInfo/index.js new file mode 100644 index 00000000000..bb61e38479f --- /dev/null +++ b/src/components/dashboard/DashboardMyInfo/index.js @@ -0,0 +1,426 @@ +// src/components/dashboard/DashboardMyInfo.jsx +import { UploadOutlined } from "@ant-design/icons"; +import { + Alert, + Avatar, + Button, + Col, + DatePicker, + Divider, + Form, + Input, + message, + Modal, + Row, + Spin, + Typography, + Upload, +} from "antd"; +import moment from "moment"; +import React, { useEffect, useState } from "react"; +import { useAuth } from "../../../context/AuthContext"; +import { + deleteAccountApi, + getUserInfo, + resendVerificationEmailApi, + updatePasswordApi, + updateProfileApi, + uploadAvatarApi, +} from "../../../utils/mockApi"; + +const { Text } = Typography; + +export default function DashboardMyInfo() { + const { token, user, setUser } = useAuth(); + const [infoLoading, setInfoLoading] = useState(false); + const [editing, setEditing] = useState(false); + const [pwdModalVisible, setPwdModalVisible] = useState(false); + const [deleteModalVisible, setDeleteModalVisible] = useState(false); + const [profileForm] = Form.useForm(); + + const [showEmailAlert, setShowEmailAlert] = useState(false); + + // 額外顯示使用者的「最後登入時間」與「Email 是否驗證」等資料 + const lastLoginTime = user?.lastLoginTime + ? moment(user.lastLoginTime).format("YYYY-MM-DD HH:mm") + : "無資料"; + + // 取得最新 user 資料 + const refreshUserInfo = async () => { + if (!token) return; + setInfoLoading(true); + try { + const data = await getUserInfo(token); + setUser(data); + + // 依據回傳的 isEmailVerified 判斷是否要顯示警告 + if (data.isEmailVerified === false) { + setShowEmailAlert(true); + } else { + setShowEmailAlert(false); + } + } catch (error) { + message.error(error.message || "取得資料失敗"); + } finally { + setInfoLoading(false); + } + }; + + useEffect(() => { + refreshUserInfo(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // 編輯個人資料 + const onEditProfile = () => { + setEditing(true); + if (user) { + profileForm.setFieldsValue({ + nickname: user.nickname, + email: user.email, + phone: user.phone, + birthday: user.birthday ? moment(user.birthday) : null, + }); + } + }; + + const onSaveProfile = async (values) => { + try { + // 將 moment 格式的 birthday 轉為字串或後端需要的格式 + const birthdayString = values.birthday + ? moment(values.birthday).format("YYYY-MM-DD") + : null; + + await updateProfileApi(token, { + ...values, + birthday: birthdayString, + }); + message.success("更新成功"); + setUser((prev) => ({ + ...prev, + nickname: values.nickname, + email: values.email, + phone: values.phone, + birthday: birthdayString, + })); + setEditing(false); + } catch (err) { + message.error(err.message || "更新失敗"); + } + }; + + // 上傳頭像 + const onUploadAvatar = async ({ file }) => { + try { + const newUrl = await uploadAvatarApi(token, file); + message.success("頭像已更新"); + setUser((prev) => ({ + ...prev, + avatar: newUrl, + })); + } catch (err) { + message.error(err.message || "頭像上傳失敗"); + } + }; + + // 變更密碼 + const openChangePwdModal = () => { + setPwdModalVisible(true); + }; + const onChangePassword = async (values) => { + if (values.newPassword !== values.confirmPassword) { + return message.error("兩次輸入密碼不一致"); + } + try { + await updatePasswordApi(token, values.oldPassword, values.newPassword); + message.success("密碼已變更!"); + setPwdModalVisible(false); + } catch (err) { + message.error(err.message || "變更密碼失敗"); + } + }; + + // 重新寄送驗證信 + const onResendVerification = async () => { + try { + await resendVerificationEmailApi(token); + message.success("驗證信已重新寄送,請檢查您的信箱"); + } catch (err) { + message.error(err.message || "寄送驗證信失敗"); + } + }; + + // 刪除帳號 + const onDeleteAccount = async () => { + try { + await deleteAccountApi(token); + message.success("帳號已刪除,將導回主頁"); + // 這裡也可以登出並導回主頁 + // window.location.href = "/"; + setDeleteModalVisible(false); + } catch (err) { + message.error(err.message || "刪除帳號失敗"); + } + }; + + if (infoLoading) { + return ; + } + + return ( +
+

我的資訊

+ + {showEmailAlert && ( + + 請至信箱收信並點擊驗證連結, + +
+ } + type="warning" + showIcon + /> + )} + + +
+
+ +
+ + + +
+ + + + {editing ? ( + + + + + + + + + + + + + + + current && current > moment()} + placeholder="選擇生日" + /> + + + + + + + + ) : ( +
+

暱稱:{user?.nickname || "(未設定)"}

+

Email:{user?.email || "未知"}

+

電話:{user?.phone || "(未設定)"}

+

+ 生日: + {user?.birthday + ? moment(user.birthday).format("YYYY-MM-DD") + : "(未設定)"} +

+ +

+ 上次登入時間:{lastLoginTime} +

+ + +
+ )} + + + + + + + + + + + + + + + {/* 密碼變更 Modal */} + setPwdModalVisible(false)} + onSubmit={onChangePassword} + /> + + {/* 刪除帳號 Modal */} + setDeleteModalVisible(false)} + onDelete={onDeleteAccount} + /> + + ); +} + +/** 變更密碼 Modal */ +function ChangePasswordModal({ visible, onCancel, onSubmit }) { + const [form] = Form.useForm(); + const [passwordStrength, setPasswordStrength] = useState(""); + + const onFinish = (values) => { + onSubmit(values); + setPasswordStrength(""); + form.resetFields(); + }; + + // 簡單示範一個密碼強度偵測 + const handlePasswordChange = (e) => { + const pwd = e.target.value; + if (pwd.length < 6) { + setPasswordStrength("弱"); + } else if (pwd.length < 10) { + setPasswordStrength("中"); + } else { + setPasswordStrength("強"); + } + }; + + return ( + { + onCancel(); + form.resetFields(); + setPasswordStrength(""); + }} + onOk={() => form.submit()} + okText="儲存" + cancelText="取消" + > +
+ + + + + + 新密碼 + {passwordStrength && ( + + (強度:{passwordStrength}) + + )} + + } + name="newPassword" + rules={[ + { required: true, message: "請輸入新密碼" }, + { min: 8, message: "至少 8 碼" }, + ]} + > + + + + ({ + validator(_, value) { + if (!value || getFieldValue("newPassword") === value) { + return Promise.resolve(); + } + return Promise.reject("兩次輸入的密碼不一致"); + }, + }), + ]} + > + + + +
+ ); +} + +/** 刪除帳號 Modal */ +function DeleteAccountModal({ visible, onCancel, onDelete }) { + return ( + +

您確定要刪除帳號嗎?此操作無法復原!

+
+ ); +} diff --git a/src/components/forms/ForgotPasswordForm.js b/src/components/forms/ForgotPasswordForm.js new file mode 100644 index 00000000000..a1a5acf1443 --- /dev/null +++ b/src/components/forms/ForgotPasswordForm.js @@ -0,0 +1,40 @@ +import { Button, Form, Input, message } from "antd"; +import React, { useState } from "react"; +import { forgotPasswordApi } from "../../utils/mockApi"; + +export default function ForgotPasswordForm({ onSuccess }) { + const [loading, setLoading] = useState(false); + + const onFinish = async (values) => { + setLoading(true); + try { + await forgotPasswordApi(values.email); + message.success("重設密碼信已寄出,請檢查您的信箱!"); + onSuccess?.(); // 成功後關閉 Modal,或跳轉其它狀態 + } catch (error) { + message.error(error.message || "寄送失敗,請稍後再試"); + } finally { + setLoading(false); + } + }; + + return ( +
+ + + + + + + + ); +} diff --git a/src/components/forms/LoginForm.js b/src/components/forms/LoginForm.js new file mode 100644 index 00000000000..f9bea5d51e4 --- /dev/null +++ b/src/components/forms/LoginForm.js @@ -0,0 +1,72 @@ +// components/forms/LoginForm.js +import { Button, Form, Input, Typography } from "antd"; +import React from "react"; + +const weakPasswords = [ + "123456", "password", "123456789", "12345678", "12345", "1234567", "qwerty", + "abc123", "password1", "111111", "123123", "admin", "welcome", "iloveyou", + "1q2w3e4r", "monkey", "sunshine", "letmein", "football", "dragon", "shadow", + "1234", "princess", "baseball", "superman", "starwars" +]; + +export default function LoginForm({ onLogin, onSuccess, loading }) { + const onFinish = async (values) => { + const ok = await onLogin(values.username, values.password); + if (ok) { + onSuccess?.(); + } + }; + + return ( +
+ + + + + ({ + validator(_, value) { + if (weakPasswords.includes(value?.toLowerCase())) { + return Promise.reject( + new Error("此密碼過於常見,請使用更安全的密碼") + ); + } + return Promise.resolve(); + }, + }), + ]} + > + + + + + + + { + // 假設要在同一個 Modal 內顯示 reset flow + // 可透過 props 或 context 切換狀態 + onToggleForgotPassword?.(); + }} + > + 忘記密碼? + + + ); +} diff --git a/src/components/forms/RegisterForm.js b/src/components/forms/RegisterForm.js new file mode 100644 index 00000000000..e82510f8405 --- /dev/null +++ b/src/components/forms/RegisterForm.js @@ -0,0 +1,45 @@ +// components/forms/RegisterForm.js +import { Button, Form, Input } from "antd"; +import React from "react"; + +export default function RegisterForm({ onRegister, onSuccess, loading }) { + const onFinish = async (values) => { + const ok = await onRegister(values.username, values.password); + if (ok) { + onSuccess?.(); + } + }; + + return ( +
+ + + + + + + + + + + + + ); +} diff --git a/src/context/AuthContext.js b/src/context/AuthContext.js new file mode 100644 index 00000000000..f741b10fc32 --- /dev/null +++ b/src/context/AuthContext.js @@ -0,0 +1,61 @@ +import React, { createContext, useContext, useEffect, useState } from "react"; +import { getUserInfo } from "../utils/mockApi"; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }) { + const [token, setToken] = useState(null); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const savedToken = localStorage.getItem("token"); + if (savedToken) { + setToken(savedToken); + getUserInfo(savedToken) + .then((data) => { + setUser(data); + setLoading(false); + }) + .catch(() => { + setToken(null); + localStorage.removeItem("token"); + setLoading(false); + }); + } else { + setLoading(false); + } + }, []); + + const loginSuccess = (loginToken) => { + setToken(loginToken); + localStorage.setItem("token", loginToken); + getUserInfo(loginToken) + .then((data) => setUser(data)) + .catch(() => { + setToken(null); + localStorage.removeItem("token"); + }); + }; + + const logout = () => { + setToken(null); + setUser(null); + localStorage.removeItem("token"); + }; + + const value = { + token, + user, + loading, + loginSuccess, + logout, + setUser, + }; + + return {children}; +} + +export function useAuth() { + return useContext(AuthContext); +} \ No newline at end of file diff --git a/src/hooks/useAuthHandler.js b/src/hooks/useAuthHandler.js new file mode 100644 index 00000000000..f36f0d4d0e6 --- /dev/null +++ b/src/hooks/useAuthHandler.js @@ -0,0 +1,46 @@ +import { message } from "antd"; +import { useState } from "react"; +import { useAuth } from "../context/AuthContext"; +import { loginApi, registerApi, socialLoginApi } from "../utils/mockApi"; + +export default function useAuthHandler() { + const { loginSuccess } = useAuth(); + const [loading, setLoading] = useState(false); + + /** + * 可透過參數自訂成功/失敗提示: + * handleAuth(apiFn, args..., { successMsg, errorMsg }) + * 若沒有傳入,預設就用「操作成功」/「操作失敗」。 + */ + const handleAuth = async (apiCall, ...args) => { + setLoading(true); + try { + const result = await apiCall(...args); + loginSuccess(result.token); + message.success("操作成功!"); + return true; + } catch (err) { + message.error(err.message || "操作失敗"); + return false; + } finally { + setLoading(false); + } + }; + + const login = (username, password) => + handleAuth(loginApi, username, password); + + const register = (username, password) => + handleAuth(registerApi, username, password); + + const socialLogin = (provider) => + handleAuth(socialLoginApi, provider); + + return { + login, + register, + socialLogin, + loading, + setLoading, + }; +} diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js new file mode 100644 index 00000000000..cb15b4e4d45 --- /dev/null +++ b/src/pages/dashboard.js @@ -0,0 +1,257 @@ +import { + CommentOutlined, + DatabaseOutlined, + HomeOutlined, + KeyOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + PoweroffOutlined, + UserOutlined, +} from "@ant-design/icons"; +import Layout from "@theme/Layout"; // Docusaurus Layout +import { + Layout as AntLayout, + Avatar, + Breadcrumb, + Button, + Col, + Dropdown, + Menu, + Row, + Spin, + theme as antdTheme, + message, +} from "antd"; +import React, { useEffect, useState } from "react"; + +import { useAuth } from "../context/AuthContext"; + +// Dashboard 子頁面 +import DashboardApiKey from "../components/dashboard/DashboardApiKey"; +import DashboardApiUsage from "../components/dashboard/DashboardApiUsage"; +import DashboardMyComments from "../components/dashboard/DashboardMyComments"; +import DashboardMyInfo from "../components/dashboard/DashboardMyInfo"; + +const { Header: AntHeader, Sider, Content, Footer } = AntLayout; +const { useToken } = antdTheme; // antd v5 提供的 useToken Hook,可讀取設計 tokens + +export default function DashboardPage() { + const { token, user, loading, logout } = useAuth(); + const [fetching, setFetching] = useState(false); + + // 預設選單指向 "myinfo" + const [selectedKey, setSelectedKey] = useState("myinfo"); + + // 側邊欄折疊狀態 + const [collapsed, setCollapsed] = useState(false); + + const { token: designToken } = useToken(); + // 這裡可讀取 antd 的設計 Token,例如 designToken.colorBgContainer + + useEffect(() => { + if (!loading && !token) { + message.warning("請先登入"); + // 例如:window.location.href = "/"; + } + }, [loading, token]); + + const renderContent = () => { + switch (selectedKey) { + case "myinfo": + return ; + case "comments": + return ; + case "apikey": + return ; + case "apiusage": + return ; + default: + return null; + } + }; + + // 顯示頂端「麵包屑 / 頁面標題」示例(若需要更動態可再擴充) + const pageTitle = (() => { + switch (selectedKey) { + case "myinfo": + return "我的資訊"; + case "comments": + return "我的留言"; + case "apikey": + return "我的 API Key"; + case "apiusage": + return "API 使用紀錄"; + default: + return "未定義"; + } + })(); + + // 建立右上角用戶選單 + const userMenu = ( + , + label: "回主站", + onClick: () => { + window.location.href = "/"; + }, + }, + { + key: "logout", + icon: , + label: "登出", + onClick: () => { + logout(); + }, + }, + ]} + /> + ); + + if (loading || fetching) { + return ( + + + + ); + } + + if (!token) { + return ( + +
+

尚未登入

+
+
+ ); + } + + return ( + + + {/* 可伸縮的側邊欄 */} + setCollapsed(value)} + style={{ + borderRight: "1px solid #ddd", + }} + > + {/* Logo 或標題區塊 */} +
+ {collapsed ? "後台" : "我的後台"} +
+ + setSelectedKey(e.key)} + items={[ + { + key: "myinfo", + icon: , + label: "我的資訊", + }, + { + key: "comments", + icon: , + label: "我的留言", + }, + { + key: "apikey", + icon: , + label: "我的 API Key", + }, + { + key: "apiusage", + icon: , + label: "API 使用紀錄", + }, + ]} + /> + + + + {/* 頂欄 */} + + +
+ + {/* 折疊按鈕 */} + + + + 我的後台 + {pageTitle} + + + + + + + +
+ + {user?.name?.[0] || "U"} + + Hi, {user?.name || "User"}! +
+
+ + + + + {/* 主要內容區塊 */} + +
+ {renderContent()} +
+
+ + {/* Footer */} +
+
+ © {new Date().getFullYear()} My Company. All rights reserved. +
+
+ + + + ); +} diff --git a/src/theme/Navbar/ColorModeToggle/index.js b/src/theme/Navbar/ColorModeToggle/index.js new file mode 100644 index 00000000000..a506863c68a --- /dev/null +++ b/src/theme/Navbar/ColorModeToggle/index.js @@ -0,0 +1,22 @@ +import React from 'react'; +import {useColorMode, useThemeConfig} from '@docusaurus/theme-common'; +import ColorModeToggle from '@theme/ColorModeToggle'; +import styles from './styles.module.css'; +export default function NavbarColorModeToggle({className}) { + const navbarStyle = useThemeConfig().navbar.style; + const disabled = useThemeConfig().colorMode.disableSwitch; + const {colorMode, setColorMode} = useColorMode(); + if (disabled) { + return null; + } + return ( + + ); +} diff --git a/src/theme/Navbar/ColorModeToggle/styles.module.css b/src/theme/Navbar/ColorModeToggle/styles.module.css new file mode 100644 index 00000000000..7bd077a6bfd --- /dev/null +++ b/src/theme/Navbar/ColorModeToggle/styles.module.css @@ -0,0 +1,3 @@ +.darkNavbarColorModeToggle:hover { + background: var(--ifm-color-gray-800); +} diff --git a/src/theme/Navbar/Content/index.js b/src/theme/Navbar/Content/index.js new file mode 100644 index 00000000000..004d20bed72 --- /dev/null +++ b/src/theme/Navbar/Content/index.js @@ -0,0 +1,149 @@ +// src/theme/Navbar/Content/index.js +import { UserOutlined } from '@ant-design/icons'; +import Link from "@docusaurus/Link"; +import { Avatar, Button, Dropdown, Menu, message } from 'antd'; +import React, { useState } from 'react'; +import { useAuth } from '../../../context/AuthContext'; + +import { ErrorCauseBoundary, useThemeConfig } from '@docusaurus/theme-common'; +import { + splitNavbarItems, + useNavbarMobileSidebar, +} from '@docusaurus/theme-common/internal'; + +import NavbarColorModeToggle from '@theme/Navbar/ColorModeToggle'; +import NavbarLogo from '@theme/Navbar/Logo'; +import NavbarMobileSidebarToggle from '@theme/Navbar/MobileSidebar/Toggle'; +import NavbarSearch from '@theme/Navbar/Search'; +import NavbarItem from '@theme/NavbarItem'; +import SearchBar from '@theme/SearchBar'; +import styles from './styles.module.css'; + +import AuthModal from '../../../components/AuthModal'; // 自訂的 Modal + +function useNavbarItems() { + return useThemeConfig().navbar.items; +} + +function NavbarItems({items}) { + return ( + <> + {items.map((item, i) => ( + + new Error( + `A theme navbar item failed to render. +Please double-check the following navbar item (themeConfig.navbar.items) of your Docusaurus config: +${JSON.stringify(item, null, 2)}`, + {cause: error}, + ) + }> + + + ))} + + ); +} + +function NavbarContentLayout({left, right}) { + return ( +
+
{left}
+
{right}
+
+ ); +} + +export default function NavbarContent() { + const mobileSidebar = useNavbarMobileSidebar(); + const items = useNavbarItems(); + // 官方 internal function,將 config 中的 items 拆分左右 + const [leftItems, rightItems] = splitNavbarItems(items); + // 如果 config 中沒有 search item, 預設用 SearchBar + const searchBarItem = items.find((item) => item.type === 'search'); + + // === Auth 狀態 / Modal === + const { token, logout } = useAuth(); + const [authVisible, setAuthVisible] = useState(false); + + const userMenu = ( + + 儀表板 + + ) + }, + { + key: 'logout', + label: ( + { + logout(); + message.success('已登出'); + }} + > + 登出 + + ), + }, + ]} + /> + ); + + return ( + <> + + {/* 手機版側邊欄切換按鈕 */} + {!mobileSidebar.disabled && } + {/* Logo */} + + {/* 左側 items */} + + + } + right={ + <> + {/* 右側 items */} + + + {/* 顯示 Theme 切換按鈕 (若你 config 有 colorMode.enableSwitch) */} + + + {/* 若 config 裡沒定義 search,預設就顯示一個 SearchBar */} + {!searchBarItem && ( + + + + )} + + {/* 登入 / 登出 / Avatar */} + {token ? ( + + } + style={{ cursor: 'pointer', backgroundColor: '#87d068' }} + /> + + ) : ( + + ); +} +export default function NavbarMobileSidebarHeader() { + return ( +
+ + + +
+ ); +} diff --git a/src/theme/Navbar/MobileSidebar/Layout/index.js b/src/theme/Navbar/MobileSidebar/Layout/index.js new file mode 100644 index 00000000000..b19eb2f962a --- /dev/null +++ b/src/theme/Navbar/MobileSidebar/Layout/index.js @@ -0,0 +1,22 @@ +import React from 'react'; +import clsx from 'clsx'; +import {useNavbarSecondaryMenu} from '@docusaurus/theme-common/internal'; +export default function NavbarMobileSidebarLayout({ + header, + primaryMenu, + secondaryMenu, +}) { + const {shown: secondaryMenuShown} = useNavbarSecondaryMenu(); + return ( +
+ {header} +
+
{primaryMenu}
+
{secondaryMenu}
+
+
+ ); +} diff --git a/src/theme/Navbar/MobileSidebar/PrimaryMenu/index.js b/src/theme/Navbar/MobileSidebar/PrimaryMenu/index.js new file mode 100644 index 00000000000..9c5d0ec0674 --- /dev/null +++ b/src/theme/Navbar/MobileSidebar/PrimaryMenu/index.js @@ -0,0 +1,27 @@ +import React from 'react'; +import {useThemeConfig} from '@docusaurus/theme-common'; +import {useNavbarMobileSidebar} from '@docusaurus/theme-common/internal'; +import NavbarItem from '@theme/NavbarItem'; +function useNavbarItems() { + // TODO temporary casting until ThemeConfig type is improved + return useThemeConfig().navbar.items; +} +// The primary menu displays the navbar items +export default function NavbarMobilePrimaryMenu() { + const mobileSidebar = useNavbarMobileSidebar(); + // TODO how can the order be defined for mobile? + // Should we allow providing a different list of items? + const items = useNavbarItems(); + return ( +
    + {items.map((item, i) => ( + mobileSidebar.toggle()} + key={i} + /> + ))} +
+ ); +} diff --git a/src/theme/Navbar/MobileSidebar/SecondaryMenu/index.js b/src/theme/Navbar/MobileSidebar/SecondaryMenu/index.js new file mode 100644 index 00000000000..dd71af856ae --- /dev/null +++ b/src/theme/Navbar/MobileSidebar/SecondaryMenu/index.js @@ -0,0 +1,30 @@ +import React from 'react'; +import {useThemeConfig} from '@docusaurus/theme-common'; +import {useNavbarSecondaryMenu} from '@docusaurus/theme-common/internal'; +import Translate from '@docusaurus/Translate'; +function SecondaryMenuBackButton(props) { + return ( + + ); +} +// The secondary menu slides from the right and shows contextual information +// such as the docs sidebar +export default function NavbarMobileSidebarSecondaryMenu() { + const isPrimaryMenuEmpty = useThemeConfig().navbar.items.length === 0; + const secondaryMenu = useNavbarSecondaryMenu(); + return ( + <> + {/* edge-case: prevent returning to the primaryMenu when it's empty */} + {!isPrimaryMenuEmpty && ( + secondaryMenu.hide()} /> + )} + {secondaryMenu.content} + + ); +} diff --git a/src/theme/Navbar/MobileSidebar/Toggle/index.js b/src/theme/Navbar/MobileSidebar/Toggle/index.js new file mode 100644 index 00000000000..bfe298891b9 --- /dev/null +++ b/src/theme/Navbar/MobileSidebar/Toggle/index.js @@ -0,0 +1,22 @@ +import React from 'react'; +import {useNavbarMobileSidebar} from '@docusaurus/theme-common/internal'; +import {translate} from '@docusaurus/Translate'; +import IconMenu from '@theme/Icon/Menu'; +export default function MobileSidebarToggle() { + const {toggle, shown} = useNavbarMobileSidebar(); + return ( + + ); +} diff --git a/src/theme/Navbar/MobileSidebar/index.js b/src/theme/Navbar/MobileSidebar/index.js new file mode 100644 index 00000000000..5db3cc7a28c --- /dev/null +++ b/src/theme/Navbar/MobileSidebar/index.js @@ -0,0 +1,23 @@ +import React from 'react'; +import { + useLockBodyScroll, + useNavbarMobileSidebar, +} from '@docusaurus/theme-common/internal'; +import NavbarMobileSidebarLayout from '@theme/Navbar/MobileSidebar/Layout'; +import NavbarMobileSidebarHeader from '@theme/Navbar/MobileSidebar/Header'; +import NavbarMobileSidebarPrimaryMenu from '@theme/Navbar/MobileSidebar/PrimaryMenu'; +import NavbarMobileSidebarSecondaryMenu from '@theme/Navbar/MobileSidebar/SecondaryMenu'; +export default function NavbarMobileSidebar() { + const mobileSidebar = useNavbarMobileSidebar(); + useLockBodyScroll(mobileSidebar.shown); + if (!mobileSidebar.shouldRender) { + return null; + } + return ( + } + primaryMenu={} + secondaryMenu={} + /> + ); +} diff --git a/src/theme/Navbar/Search/index.js b/src/theme/Navbar/Search/index.js new file mode 100644 index 00000000000..686f73b8d81 --- /dev/null +++ b/src/theme/Navbar/Search/index.js @@ -0,0 +1,10 @@ +import React from 'react'; +import clsx from 'clsx'; +import styles from './styles.module.css'; +export default function NavbarSearch({children, className}) { + return ( +
+ {children} +
+ ); +} diff --git a/src/theme/Navbar/Search/styles.module.css b/src/theme/Navbar/Search/styles.module.css new file mode 100644 index 00000000000..e6bbbd24c3d --- /dev/null +++ b/src/theme/Navbar/Search/styles.module.css @@ -0,0 +1,21 @@ +/* +Workaround to avoid rendering empty search container +See https://github.com/facebook/docusaurus/pull/9385 +*/ +.navbarSearchContainer:empty { + display: none; +} + +@media (max-width: 996px) { + .navbarSearchContainer { + position: relative; + right: var(--ifm-navbar-padding-horizontal); + } +} + +@media (min-width: 997px) { + .navbarSearchContainer { + padding: var(--ifm-navbar-item-padding-vertical) + var(--ifm-navbar-item-padding-horizontal); + } +} diff --git a/src/theme/Navbar/index.js b/src/theme/Navbar/index.js new file mode 100644 index 00000000000..d18a258ca9a --- /dev/null +++ b/src/theme/Navbar/index.js @@ -0,0 +1,10 @@ +import React from 'react'; +import NavbarLayout from '@theme/Navbar/Layout'; +import NavbarContent from '@theme/Navbar/Content'; +export default function Navbar() { + return ( + + + + ); +} diff --git a/src/theme/Root/index.js b/src/theme/Root/index.js new file mode 100644 index 00000000000..2fb50921b40 --- /dev/null +++ b/src/theme/Root/index.js @@ -0,0 +1,6 @@ +import React from "react"; +import { AuthProvider } from "../../context/AuthContext"; + +export default function Root({ children }) { + return {children}; +} diff --git a/src/utils/mockApi.js b/src/utils/mockApi.js new file mode 100644 index 00000000000..ed487e22b03 --- /dev/null +++ b/src/utils/mockApi.js @@ -0,0 +1,223 @@ +// mockApi.js + +export async function loginApi(username, password) { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (!username || !password) { + reject(new Error("登入失敗,請輸入帳號與密碼")); + } else { + if (username === "admin" && password === "admin123") { + resolve({ token: "fake-admin-token" }); + } else { + resolve({ token: "fake-jwt-token" }); + } + } + }, 1000); + }); +} + +export async function registerApi(username, password) { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (!username || !password) { + reject(new Error("註冊失敗,請輸入帳號與密碼")); + } else { + resolve({ token: "fake-register-token" }); + } + }, 1000); + }); +} + +export async function getUserInfo(token) { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (token) { + resolve({ id: 1, name: "Mock User" }); + } else { + reject(new Error("無效的 token")); + } + }, 800); + }); +} + +export async function socialLoginApi(provider) { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (provider === "Google" || provider === "Facebook") { + resolve({ token: `fake-${provider.toLowerCase()}-token` }); + } else { + reject(new Error("不支援此社群登入方式")); + } + }, 1200); + }); +} + +// ↓↓↓新增↓↓↓ +export async function forgotPasswordApi(email) { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (!email) { + reject(new Error("請輸入 Email")); + } else if (email.includes("@")) { + resolve(true); + } else { + reject(new Error("Email 格式不正確,或該 Email 不存在於系統")); + } + }, 1000); + }); +} + +export async function resetPasswordApi(token, newPassword) { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (!token) { + reject(new Error("無效的重設密碼連結或 Token")); + } else if (!newPassword) { + reject(new Error("請輸入新密碼")); + } else if (newPassword.length < 8) { + reject(new Error("新密碼至少 8 碼")); + } else { + resolve(true); + } + }, 1000); + }); +} + +export async function updateProfileApi(token, { name, email }) { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (!token) { + return reject(new Error("尚未登入,無法更新")); + } + resolve(true); + }, 1000); + }); +} + +export async function updatePasswordApi(token, oldPassword, newPassword) { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (!token) { + return reject(new Error("尚未登入")); + } + if (!oldPassword || !newPassword) { + return reject(new Error("密碼資料不完整")); + } + resolve(true); + }, 1000); + }); +} + +export async function uploadAvatarApi(token, file) { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (!token) { + return reject(new Error("尚未登入")); + } + const newUrl = "https://via.placeholder.com/100?text=New+Avatar"; + resolve(newUrl); + }, 1500); + }); +} + +export async function getMyCommentsApi() { + return new Promise((resolve) => { + setTimeout(() => { + resolve([ + { id: 101, content: "Great post!", createdAt: "2023-01-01 10:20" }, + { id: 102, content: "Nice article.", createdAt: "2023-02-02 15:10" }, + ]); + }, 800); + }); +} + +export async function updateCommentApi(commentId, newContent) { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (!commentId || !newContent) { + return reject(new Error("更新留言失敗")); + } + resolve(true); + }, 500); + }); +} + +export async function deleteCommentApi(commentId) { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (!commentId) { + return reject(new Error("無法刪除")); + } + resolve(true); + }, 500); + }); +} + +// API Key +export async function getMyApiKeyApi() { + return new Promise((resolve) => { + setTimeout(() => { + resolve("fake-api-key-123456789"); + }, 600); + }); +} + +export async function regenerateApiKeyApi() { + return new Promise((resolve) => { + setTimeout(() => { + const newKey = + "fake-api-key-" + Math.random().toString(36).slice(2, 8); + resolve(newKey); + }, 800); + }); +} + +// API Usage +export async function getApiUsageApi() { + return new Promise((resolve) => { + setTimeout(() => { + resolve([ + { + id: 1, + timestamp: "2023-03-01 09:15:00", + endpoint: "/v1/some-api", + statusCode: 200, + latency: 123, + }, + { + id: 2, + timestamp: "2023-03-01 09:16:10", + endpoint: "/v1/some-api", + statusCode: 400, + latency: 45, + }, + ]); + }, 600); + }); +} + +/** 重新寄送 Email 驗證信 */ +export async function resendVerificationEmailApi(token) { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (!token) { + return reject(new Error("尚未登入")); + } + // 假裝寄出成功 + resolve(true); + }, 1200); + }); +} + +/** 刪除帳號 */ +export async function deleteAccountApi(token) { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (!token) { + return reject(new Error("尚未登入")); + } + // 假裝刪除成功 + resolve(true); + }, 1000); + }); +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 2e06116dde7..15cc73ad4d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -199,6 +199,24 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" +"@ant-design/charts-util@0.0.1-alpha.7": + version "0.0.1-alpha.7" + resolved "https://registry.yarnpkg.com/@ant-design/charts-util/-/charts-util-0.0.1-alpha.7.tgz#39152b7106970faa226ba857fae64a0eb32f30b9" + integrity sha512-Yh0o6EdO6SvdSnStFZMbnUzjyymkVzV+TQ9ymVW9hlVgO/fUkUII3JYSdV+UVcFnYwUF0YiDKuSTLCZNAzg2bQ== + dependencies: + lodash "^4.17.21" + +"@ant-design/charts@^2.2.6": + version "2.2.6" + resolved "https://registry.yarnpkg.com/@ant-design/charts/-/charts-2.2.6.tgz#2e294335399407f14eba5e10d4caf8bb790c80d5" + integrity sha512-pkzdIkc+TdHejOkjnizIXleCQ3psqNQSStllhHul4Ep82IU/qfTbGZ9iaVGJoFOc0+uHBO9Y65RVS47TPMBerQ== + dependencies: + "@ant-design/graphs" "^2.0.4" + "@ant-design/plots" "^2.3.2" + lodash "^4.17.21" + react "^18.3.1" + react-dom "^18.3.1" + "@ant-design/colors@^7.0.0", "@ant-design/colors@^7.2.0": version "7.2.0" resolved "https://registry.yarnpkg.com/@ant-design/colors/-/colors-7.2.0.tgz#80d7325d20463f09c7839d28da630043dd5c263a" @@ -228,6 +246,19 @@ rc-util "^5.35.0" stylis "^4.3.4" +"@ant-design/cssinjs@^1.21.1": + version "1.23.0" + resolved "https://registry.yarnpkg.com/@ant-design/cssinjs/-/cssinjs-1.23.0.tgz#492efba9b15d64f42a4cb5d568cab0607d0c2b16" + integrity sha512-7GAg9bD/iC9ikWatU9ym+P9ugJhi/WbsTWzcKN6T4gU0aehsprtke1UAaaSxxkjjmkJb3llet/rbUSLPgwlY4w== + dependencies: + "@babel/runtime" "^7.11.1" + "@emotion/hash" "^0.8.0" + "@emotion/unitless" "^0.7.5" + classnames "^2.3.1" + csstype "^3.1.3" + rc-util "^5.35.0" + stylis "^4.3.4" + "@ant-design/fast-color@^2.0.6": version "2.0.6" resolved "https://registry.yarnpkg.com/@ant-design/fast-color/-/fast-color-2.0.6.tgz#ab4d4455c1542c9017d367c2fa8ca3e4215d0ba2" @@ -235,11 +266,36 @@ dependencies: "@babel/runtime" "^7.24.7" +"@ant-design/graphs@^2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@ant-design/graphs/-/graphs-2.0.4.tgz#49f52a4bf2b616070b505f06ecce47d65451381f" + integrity sha512-8Os7/it/auQt7NWUA+Ml+NjVYz2VsRjaGJYGxlUnfT4w0KiW6A9aYgixjVqWYjsdNBK0J3lHtrlME5RLiJ5DTw== + dependencies: + "@ant-design/charts-util" "0.0.1-alpha.7" + "@antv/g6" "^5.0.38" + "@antv/g6-extension-react" "^0.1.13" + "@antv/graphin" "^3.0.4" + lodash "^4.17.21" + react "^18.3.1" + react-dom "^18.3.1" + styled-components "^6.1.13" + "@ant-design/icons-svg@^4.4.0": version "4.4.2" resolved "https://registry.yarnpkg.com/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz#ed2be7fb4d82ac7e1d45a54a5b06d6cecf8be6f6" integrity sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA== +"@ant-design/icons@^5.0.0": + version "5.6.1" + resolved "https://registry.yarnpkg.com/@ant-design/icons/-/icons-5.6.1.tgz#7290fcdc3d96ff3fca793ed399053cd29ad5dbd3" + integrity sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg== + dependencies: + "@ant-design/colors" "^7.0.0" + "@ant-design/icons-svg" "^4.4.0" + "@babel/runtime" "^7.24.8" + classnames "^2.2.6" + rc-util "^5.31.1" + "@ant-design/icons@^5.5.2": version "5.5.2" resolved "https://registry.yarnpkg.com/@ant-design/icons/-/icons-5.5.2.tgz#c4567943cc2b7c6dbe9cae68c06ffa35f755dc0d" @@ -251,6 +307,83 @@ classnames "^2.2.6" rc-util "^5.31.1" +"@ant-design/plots@^2.3.2": + version "2.3.3" + resolved "https://registry.yarnpkg.com/@ant-design/plots/-/plots-2.3.3.tgz#5f004973c16937f0a5019f290a0234ac15251639" + integrity sha512-t+pMXuCNEnbrPsRsIMyPSVMYKnFuVYeqHwtjg3ImfTTBdUAfqnJfCBnyPVakPRoMRV2y9+uVL8YWrOQiEvc6rg== + dependencies: + "@ant-design/charts-util" "0.0.1-alpha.7" + "@antv/event-emitter" "^0.1.3" + "@antv/g" "^6.1.7" + "@antv/g2" "^5.2.7" + "@antv/g2-extension-plot" "^0.2.1" + lodash "^4.17.21" + react "^18.3.1" + react-dom "^18.3.1" + +"@ant-design/pro-card@^2.9.6": + version "2.9.6" + resolved "https://registry.yarnpkg.com/@ant-design/pro-card/-/pro-card-2.9.6.tgz#b3bda5bbd6732e889eb4a4056cc31be5e40b7857" + integrity sha512-boUvowODMhc1l55ZZj/08YwnaggL50fAio2NaA7uXsgpbLduSPL2OE0RyiI24NCqFhPRZMZQHbPOmcHw4Bf3yQ== + dependencies: + "@ant-design/cssinjs" "^1.21.1" + "@ant-design/icons" "^5.0.0" + "@ant-design/pro-provider" "2.15.3" + "@ant-design/pro-utils" "2.16.4" + "@babel/runtime" "^7.18.0" + classnames "^2.3.2" + rc-resize-observer "^1.0.0" + rc-util "^5.4.0" + +"@ant-design/pro-layout@^7.22.3": + version "7.22.3" + resolved "https://registry.yarnpkg.com/@ant-design/pro-layout/-/pro-layout-7.22.3.tgz#0b4b93f16674f59ac0214a65362857968a3e2f92" + integrity sha512-di/EOMDuoMDRjBweqesYyCxEYr2LCmO82y6A4bSwmmJ6ehxN7HGC73Wx4RuBkzDR7kHLTOXt7WxI6875ENT8mg== + dependencies: + "@ant-design/cssinjs" "^1.21.1" + "@ant-design/icons" "^5.0.0" + "@ant-design/pro-provider" "2.15.3" + "@ant-design/pro-utils" "2.16.4" + "@babel/runtime" "^7.18.0" + "@umijs/route-utils" "^4.0.0" + "@umijs/use-params" "^1.0.9" + classnames "^2.3.2" + lodash "^4.17.21" + lodash-es "^4.17.21" + path-to-regexp "8.2.0" + rc-resize-observer "^1.1.0" + rc-util "^5.0.6" + swr "^2.0.0" + warning "^4.0.3" + +"@ant-design/pro-provider@2.15.3": + version "2.15.3" + resolved "https://registry.yarnpkg.com/@ant-design/pro-provider/-/pro-provider-2.15.3.tgz#f0cc4272a2170ce6b9dcb69bf209fb5c4ee5446b" + integrity sha512-jUBCuRrhAXNMumSZ++704/zEg/7U1k2N3jMVBgtirvVaCAk5O9iZQKK4W3O3LRFc+D8yO16sXjsxhawvdGL4cA== + dependencies: + "@ant-design/cssinjs" "^1.21.1" + "@babel/runtime" "^7.18.0" + "@ctrl/tinycolor" "^3.4.0" + dayjs "^1.11.10" + rc-util "^5.0.1" + swr "^2.0.0" + +"@ant-design/pro-utils@2.16.4": + version "2.16.4" + resolved "https://registry.yarnpkg.com/@ant-design/pro-utils/-/pro-utils-2.16.4.tgz#d6d93e85d52f481965a8d704f7785c6c529aba7c" + integrity sha512-PFxqF0fsUsLj8ORvJSuMgVv9NDHwAxZaglzPN/u3jZX7rWYcrHD04EMJEXooZaSyT6Q4+j7SqXDx6oBsdb9zNw== + dependencies: + "@ant-design/icons" "^5.0.0" + "@ant-design/pro-provider" "2.15.3" + "@babel/runtime" "^7.18.0" + classnames "^2.3.2" + dayjs "^1.11.10" + lodash "^4.17.21" + lodash-es "^4.17.21" + rc-util "^5.0.6" + safe-stable-stringify "^2.4.3" + swr "^2.0.0" + "@ant-design/react-slick@~1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@ant-design/react-slick/-/react-slick-1.1.2.tgz#f84ce3e4d0dc941f02b16f1d1d6d7a371ffbb4f1" @@ -262,6 +395,448 @@ resize-observer-polyfill "^1.5.1" throttle-debounce "^5.0.0" +"@antv/algorithm@^0.1.26": + version "0.1.26" + resolved "https://registry.yarnpkg.com/@antv/algorithm/-/algorithm-0.1.26.tgz#e3f5e7f1d8db5b415c3f31e32b119cbcafc8f5de" + integrity sha512-DVhcFSQ8YQnMNW34Mk8BSsfc61iC1sAnmcfYoXTAshYHuU50p/6b7x3QYaGctDNKWGvi1ub7mPcSY0bK+aN0qg== + dependencies: + "@antv/util" "^2.0.13" + tslib "^2.0.0" + +"@antv/component@^2.1.2": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@antv/component/-/component-2.1.2.tgz#578a08abc1e70755dd2a2bad85ad7015dcfecf84" + integrity sha512-5nC9i9lh5rBHE+pk4TNnerLe4mn5874YHHhvv6EdL618UkgpdKJL0hJu4l7uAYjZ3g46VBK+IYT7md0FYv8f4w== + dependencies: + "@antv/g" "^6.1.11" + "@antv/scale" "^0.4.16" + "@antv/util" "^3.3.10" + svg-path-parser "^1.1.0" + +"@antv/coord@^0.4.7": + version "0.4.7" + resolved "https://registry.yarnpkg.com/@antv/coord/-/coord-0.4.7.tgz#3ef6c6e3f9ca0f024b90888549946061f35df77a" + integrity sha512-UTbrMLhwJUkKzqJx5KFnSRpU3BqrdLORJbwUbHK2zHSCT3q3bjcFA//ZYLVfIlwqFDXp/hzfMyRtp0c77A9ZVA== + dependencies: + "@antv/scale" "^0.4.12" + "@antv/util" "^2.0.13" + gl-matrix "^3.4.3" + +"@antv/event-emitter@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@antv/event-emitter/-/event-emitter-0.1.3.tgz#3e06323b9dcd55a3241ddc7c5458cfabd2095164" + integrity sha512-4ddpsiHN9Pd4UIlWuKVK1C4IiZIdbwQvy9i7DUSI3xNJ89FPUFt8lxDYj8GzzfdllV0NkJTRxnG+FvLk0llidg== + +"@antv/g-camera-api@2.0.35": + version "2.0.35" + resolved "https://registry.yarnpkg.com/@antv/g-camera-api/-/g-camera-api-2.0.35.tgz#0c8f5824f4525b2fed9941170aa9e668b9c5734f" + integrity sha512-z4WKmB6yN2fFi9EnapjuHbFVF0ilhMrWo2eZCxYXcb0dV5MiflU/WZi/bjs4WqVMJPNtYKx+yZhTyROncEiglw== + dependencies: + "@antv/g-lite" "2.2.16" + "@antv/util" "^3.3.5" + "@babel/runtime" "^7.25.6" + gl-matrix "^3.4.3" + tslib "^2.5.3" + +"@antv/g-canvas@^2.0.29", "@antv/g-canvas@^2.0.32": + version "2.0.40" + resolved "https://registry.yarnpkg.com/@antv/g-canvas/-/g-canvas-2.0.40.tgz#4d550e891c6bc2e51acc42eb877c21e722add68d" + integrity sha512-Starh5g+ydOFKzfK/GpwnLwz+o6UZNHkyWBXdHx2ax/AJWHVXwjyCadt/6kkc2non0ts2ow/hpJaW7X3dgdDxQ== + dependencies: + "@antv/g-lite" "2.2.16" + "@antv/g-plugin-canvas-path-generator" "2.1.16" + "@antv/g-plugin-canvas-picker" "2.1.19" + "@antv/g-plugin-canvas-renderer" "2.2.19" + "@antv/g-plugin-dom-interaction" "2.1.21" + "@antv/g-plugin-html-renderer" "2.1.21" + "@antv/g-plugin-image-loader" "2.1.19" + "@antv/util" "^3.3.5" + "@babel/runtime" "^7.25.6" + tslib "^2.5.3" + +"@antv/g-dom-mutation-observer-api@2.0.32": + version "2.0.32" + resolved "https://registry.yarnpkg.com/@antv/g-dom-mutation-observer-api/-/g-dom-mutation-observer-api-2.0.32.tgz#171361ff66970c620fd5320b51dd79e4add3631f" + integrity sha512-50r7en1+doUtR9uXmFJk8YtENQ/+DFcj2g3a4XKu9xp58kmF2qBgtdst9n1deqGcL5s0ufX/Ck9rUhtHwka+Ow== + dependencies: + "@antv/g-lite" "2.2.16" + "@babel/runtime" "^7.25.6" + +"@antv/g-lite@2.2.16": + version "2.2.16" + resolved "https://registry.yarnpkg.com/@antv/g-lite/-/g-lite-2.2.16.tgz#3aad1e45c7dca71d536ec7874d5dfb8a9ed4fdcb" + integrity sha512-473r6S5srkxUiUxI3ZkrM74HMkgyO9+2HR1xtJ75yDOOuT8F6osdXDgy0Or5cWqOlsVjiN3L3DaPnQLHlUGO5A== + dependencies: + "@antv/g-math" "3.0.0" + "@antv/util" "^3.3.5" + "@antv/vendor" "^1.0.3" + "@babel/runtime" "^7.25.6" + eventemitter3 "^5.0.1" + gl-matrix "^3.4.3" + rbush "^3.0.1" + tslib "^2.5.3" + +"@antv/g-math@3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@antv/g-math/-/g-math-3.0.0.tgz#834d993391546e39ae5a30452572fdc49a7c57ec" + integrity sha512-AkmiNIEL1vgqTPeGY2wtsMdBBqKFwF7SKSgs+D1iOS/rqYMsXdhp/HvtuQ5tx/HdawE/ZzTiicIYopc520ADZw== + dependencies: + "@antv/util" "^3.3.5" + gl-matrix "^3.4.3" + tslib "^2.5.3" + +"@antv/g-plugin-canvas-path-generator@2.1.16": + version "2.1.16" + resolved "https://registry.yarnpkg.com/@antv/g-plugin-canvas-path-generator/-/g-plugin-canvas-path-generator-2.1.16.tgz#f60dfa687027aba12aed90d64839b97b2c3c3be0" + integrity sha512-E3/HUzWRv1/5QyKHLcXIgFJff0JBxDHz4NfHwYp6IOy5P/A1mbISsUjwafSl8JIVqx0J81CzgqpwU7pWHeXlaQ== + dependencies: + "@antv/g-lite" "2.2.16" + "@antv/g-math" "3.0.0" + "@antv/util" "^3.3.5" + "@babel/runtime" "^7.25.6" + tslib "^2.5.3" + +"@antv/g-plugin-canvas-picker@2.1.19": + version "2.1.19" + resolved "https://registry.yarnpkg.com/@antv/g-plugin-canvas-picker/-/g-plugin-canvas-picker-2.1.19.tgz#d4503b1819808703c765a51b0e3bccef5de4e4cd" + integrity sha512-69G0m2v09FimmYSU+hO1wjft1FqM467Cf1jDpjBz6Y3caQ98Hrqpz/7Prko1hMOALCo92MDo65yTTnz/LhBiQA== + dependencies: + "@antv/g-lite" "2.2.16" + "@antv/g-math" "3.0.0" + "@antv/g-plugin-canvas-path-generator" "2.1.16" + "@antv/g-plugin-canvas-renderer" "2.2.19" + "@antv/util" "^3.3.5" + "@babel/runtime" "^7.25.6" + gl-matrix "^3.4.3" + tslib "^2.5.3" + +"@antv/g-plugin-canvas-renderer@2.2.19": + version "2.2.19" + resolved "https://registry.yarnpkg.com/@antv/g-plugin-canvas-renderer/-/g-plugin-canvas-renderer-2.2.19.tgz#90911b38ec15edbbc946f7201ce9090bd22a43cd" + integrity sha512-3Ac0pjU0NAafu0rwTnthwWV/CV5kV9CpTf96v1CCXX0P3iPWtW72SatQNOt/v2aQ2NjYB34YuwYy9i0U1oS8rg== + dependencies: + "@antv/g-lite" "2.2.16" + "@antv/g-math" "3.0.0" + "@antv/g-plugin-canvas-path-generator" "2.1.16" + "@antv/g-plugin-image-loader" "2.1.19" + "@antv/util" "^3.3.5" + "@babel/runtime" "^7.25.6" + gl-matrix "^3.4.3" + tslib "^2.5.3" + +"@antv/g-plugin-dom-interaction@2.1.21": + version "2.1.21" + resolved "https://registry.yarnpkg.com/@antv/g-plugin-dom-interaction/-/g-plugin-dom-interaction-2.1.21.tgz#7a764b270a2da8fc367a763231071f38d5ac49cf" + integrity sha512-Vm8yeNjZ2aNgNH3LwDRExRChpuVv0Wv2zOblUGy5rgyRIh2Fkm8R89pKLmd3GlLo4AF1ZqAGWHiY2WOeMHEEIA== + dependencies: + "@antv/g-lite" "2.2.16" + "@babel/runtime" "^7.25.6" + tslib "^2.5.3" + +"@antv/g-plugin-dragndrop@^2.0.22", "@antv/g-plugin-dragndrop@^2.0.25": + version "2.0.32" + resolved "https://registry.yarnpkg.com/@antv/g-plugin-dragndrop/-/g-plugin-dragndrop-2.0.32.tgz#31559d38c5401a5116a6a8b7c64ba8c939208186" + integrity sha512-0Y9S/jx6Z7O3hEQhqrXGWNIcV1dBoRpokSP9gIMqTxOjCLzVUFYv8pFoI+Uyeow6PAWe+gdBQu+EJgVi223lJQ== + dependencies: + "@antv/g-lite" "2.2.16" + "@antv/util" "^3.3.5" + "@babel/runtime" "^7.25.6" + tslib "^2.5.3" + +"@antv/g-plugin-html-renderer@2.1.21": + version "2.1.21" + resolved "https://registry.yarnpkg.com/@antv/g-plugin-html-renderer/-/g-plugin-html-renderer-2.1.21.tgz#2077e5eae60c818962f275f3cf73044a0c8aaa88" + integrity sha512-1PR9rYt4BgSx8LFnVPF+cPlcBYKfI7iWK/xPipEa3jZ4j/xftELQ5EEyZpfPnrTqu2PtKeMurx7oaM/HPsgaiQ== + dependencies: + "@antv/g-lite" "2.2.16" + "@antv/util" "^3.3.5" + "@babel/runtime" "^7.25.6" + gl-matrix "^3.4.3" + tslib "^2.5.3" + +"@antv/g-plugin-image-loader@2.1.19": + version "2.1.19" + resolved "https://registry.yarnpkg.com/@antv/g-plugin-image-loader/-/g-plugin-image-loader-2.1.19.tgz#c94e63de91b99c7384ecd758687e78a21b1a202b" + integrity sha512-ZjNs08RkzdDMLlEWGabJG1Lu1Q71afStSlhcIRhrDOLB4tH0UdYlq/f72tlzJ6KjtLnril/xQH3D7znPlfAoig== + dependencies: + "@antv/g-lite" "2.2.16" + "@antv/util" "^3.3.5" + "@babel/runtime" "^7.25.6" + gl-matrix "^3.4.3" + tslib "^2.5.3" + +"@antv/g-plugin-svg-picker@2.0.34": + version "2.0.34" + resolved "https://registry.yarnpkg.com/@antv/g-plugin-svg-picker/-/g-plugin-svg-picker-2.0.34.tgz#3bedaa9b4cad522e06c133614f8b9f1b6df1a510" + integrity sha512-++TjTwG832tvqjP3wlEJwkZvWH/lVHxMZQDG/3knUrkUAQfNNGiEQDIjDhwzUgkYd+J48cTZiVNHzmIZM9rbrQ== + dependencies: + "@antv/g-lite" "2.2.16" + "@antv/g-plugin-svg-renderer" "2.2.16" + "@babel/runtime" "^7.25.6" + tslib "^2.5.3" + +"@antv/g-plugin-svg-renderer@2.2.16": + version "2.2.16" + resolved "https://registry.yarnpkg.com/@antv/g-plugin-svg-renderer/-/g-plugin-svg-renderer-2.2.16.tgz#c27a73a39cb593c32e6fe06d10f4eba130b6df2c" + integrity sha512-d/zZV+af8e1p1kEvRGtC57dXPZbHKKlyJ12jfnUlEFT+GhKpX3LNehc0LiO81dbpEsiZK9ffqJZMQrqsVAPWZg== + dependencies: + "@antv/g-lite" "2.2.16" + "@antv/util" "^3.3.5" + "@babel/runtime" "^7.25.6" + gl-matrix "^3.4.3" + tslib "^2.5.3" + +"@antv/g-svg@^2.0.27": + version "2.0.34" + resolved "https://registry.yarnpkg.com/@antv/g-svg/-/g-svg-2.0.34.tgz#7de602342f5eb75239a3d97854816be7d656f3f2" + integrity sha512-jx8BGZ1cNugbI8YMvJXabVauAj++Agzj6YUomTLjz7EE6K9rYwgqRb2YSV7NoRGGTfMqfIcYu+qMCoBWWsNEPg== + dependencies: + "@antv/g-lite" "2.2.16" + "@antv/g-plugin-dom-interaction" "2.1.21" + "@antv/g-plugin-svg-picker" "2.0.34" + "@antv/g-plugin-svg-renderer" "2.2.16" + "@antv/util" "^3.3.5" + "@babel/runtime" "^7.25.6" + tslib "^2.5.3" + +"@antv/g-web-animations-api@2.1.21": + version "2.1.21" + resolved "https://registry.yarnpkg.com/@antv/g-web-animations-api/-/g-web-animations-api-2.1.21.tgz#4f8fc78d766a0dc4d51d4e37a917a91c59eb02fb" + integrity sha512-EkIjeEH3QzHkDJn3sz1Mk83PqVQXGe5440mJV42QmnxuFuFcxGVJMi9vS8Te7kCUJl4eSb/eqnNi5AWfDMWm+w== + dependencies: + "@antv/g-lite" "2.2.16" + "@antv/util" "^3.3.5" + "@babel/runtime" "^7.25.6" + tslib "^2.5.3" + +"@antv/g2-extension-plot@^0.2.1": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@antv/g2-extension-plot/-/g2-extension-plot-0.2.1.tgz#664a3cdf2d4d708ed1231ee90d984812b6226d29" + integrity sha512-WNv/LIUNJLwlfG8XXmKUbje9PbImtJqh36UDvuOk/uu+kmP/uMyHAXsBuu0yCOWdQgBVTVwoxszxJOCnY4mVfg== + dependencies: + "@antv/g2" "^5.1.8" + "@antv/util" "^3.3.5" + d3-array "^3.2.4" + d3-hierarchy "^3.1.2" + +"@antv/g2@^5.1.8", "@antv/g2@^5.2.7": + version "5.2.11" + resolved "https://registry.yarnpkg.com/@antv/g2/-/g2-5.2.11.tgz#e79300383a8cc837aa1a94ebde6c29b5521d2d63" + integrity sha512-MRoiUwC6BMuxQ+or0BpWhIVjXoAsreUVp7PKpr/c0b0mNA7uzsM+rZfczDxZbJZ6k+zyQgyagjagfamS88w66g== + dependencies: + "@antv/component" "^2.1.2" + "@antv/coord" "^0.4.7" + "@antv/event-emitter" "^0.1.3" + "@antv/g" "^6.1.11" + "@antv/g-canvas" "^2.0.29" + "@antv/g-plugin-dragndrop" "^2.0.22" + "@antv/scale" "^0.4.16" + "@antv/util" "^3.3.10" + "@antv/vendor" "1.0.6" + flru "^1.0.2" + fmin "0.0.2" + pdfast "^0.2.0" + +"@antv/g6-extension-react@^0.1.13": + version "0.1.18" + resolved "https://registry.yarnpkg.com/@antv/g6-extension-react/-/g6-extension-react-0.1.18.tgz#0dbf93d7297eef4db0c2aac0067f13c3624545ff" + integrity sha512-p1hkm6gc6gPp//6alRr6gcERUX0mZQ5KxxErJWy8pCGnuSHr5oDo2Pqq7ugyK6hhH5i/zxebNTnM9uXDvMUiJA== + dependencies: + "@antv/g" "^6.1.14" + "@antv/g-svg" "^2.0.27" + "@antv/react-g" "^2.0.30" + +"@antv/g6@^5.0.28", "@antv/g6@^5.0.38": + version "5.0.43" + resolved "https://registry.yarnpkg.com/@antv/g6/-/g6-5.0.43.tgz#a73c19805f533393f7e50abc4612e26b7863d6ff" + integrity sha512-xlx3P5G74gMhQII44RrWHtu+RIXIwxRRGxeozkd0iqteBdbcpGfGHCRH6TH8Qd084i6GIXc9C1Z1n0XOJlz/uQ== + dependencies: + "@antv/algorithm" "^0.1.26" + "@antv/component" "^2.1.2" + "@antv/event-emitter" "^0.1.3" + "@antv/g" "^6.1.14" + "@antv/g-canvas" "^2.0.32" + "@antv/g-plugin-dragndrop" "^2.0.25" + "@antv/graphlib" "^2.0.4" + "@antv/hierarchy" "^0.6.14" + "@antv/layout" "1.2.14-beta.9" + "@antv/util" "^3.3.10" + bubblesets-js "^2.3.4" + hull.js "^1.0.6" + +"@antv/g@6.1.21", "@antv/g@^6.1.11", "@antv/g@^6.1.14", "@antv/g@^6.1.7": + version "6.1.21" + resolved "https://registry.yarnpkg.com/@antv/g/-/g-6.1.21.tgz#d64e5dc8ab07a9ec6b14ed671923b7dfe4b4fc05" + integrity sha512-3cWmsY1bYwDmVzsFmBeqN1tWVt+3JaWL6Uu54C1oF7qn1VXXa3V3KuXGEYCxuei8E8BMriN3D7fZosY5d+MQqw== + dependencies: + "@antv/g-camera-api" "2.0.35" + "@antv/g-dom-mutation-observer-api" "2.0.32" + "@antv/g-lite" "2.2.16" + "@antv/g-web-animations-api" "2.1.21" + "@babel/runtime" "^7.25.6" + +"@antv/graphin@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@antv/graphin/-/graphin-3.0.4.tgz#33f4ead798d8f1fa1bf885247c68ca94d18e17c4" + integrity sha512-7ce6RDI5Z6ud93yiyS7b+mmFrHJhlkwwNo53kb7P7KoCsnV7ioMONDE6Gw0ROeMSR6TwHtxGZUhHw9wxnPp82Q== + dependencies: + "@antv/g6" "^5.0.28" + +"@antv/graphlib@^2.0.0", "@antv/graphlib@^2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@antv/graphlib/-/graphlib-2.0.4.tgz#7cc4352c91125f1a3ec13852220286fe590568ee" + integrity sha512-zc/5oQlsdk42Z0ib1mGklwzhJ5vczLFiPa1v7DgJkTbgJ2YxRh9xdarf86zI49sKVJmgbweRpJs7Nu5bIiwv4w== + dependencies: + "@antv/event-emitter" "^0.1.3" + +"@antv/hierarchy@^0.6.14": + version "0.6.14" + resolved "https://registry.yarnpkg.com/@antv/hierarchy/-/hierarchy-0.6.14.tgz#4e8b4966c9c2a44aaa6f9da7008c4bd44d490385" + integrity sha512-V3uknf7bhynOqQDw2sg+9r9DwZ9pc6k/EcqyTFdfXB1+ydr7urisP0MipIuimucvQKN+Qkd+d6w601r1UIroqQ== + +"@antv/layout@1.2.14-beta.9": + version "1.2.14-beta.9" + resolved "https://registry.yarnpkg.com/@antv/layout/-/layout-1.2.14-beta.9.tgz#5c66a0f22158c545aabd1654a50bfc8c3bf93f98" + integrity sha512-wPlwBFMtq2lWZFc89/7Lzb8fjHnyKVZZ9zBb2h+zZIP0YWmVmHRE8+dqCiPKOyOGUXEdDtn813f1g107dCHZlg== + dependencies: + "@antv/event-emitter" "^0.1.3" + "@antv/graphlib" "^2.0.0" + "@antv/util" "^3.3.2" + "@naoak/workerize-transferable" "^0.1.0" + comlink "^4.4.1" + d3-force "^3.0.0" + d3-force-3d "^3.0.5" + d3-octree "^1.0.2" + d3-quadtree "^3.0.1" + dagre "^0.8.5" + ml-matrix "^6.10.4" + tslib "^2.5.0" + +"@antv/react-g@^2.0.30": + version "2.0.37" + resolved "https://registry.yarnpkg.com/@antv/react-g/-/react-g-2.0.37.tgz#a856e1c219631311267b5be469e4ae8458a5ce0f" + integrity sha512-qXued1ZtJHdf8d6aApMLCMHFCZI0lLTjpv6vEpbUWHOGjHolwDTilQy/207UXu2nFd8VbJvnPNnvVyicWFmiKw== + dependencies: + "@antv/g" "6.1.21" + "@antv/util" "^3.3.5" + "@babel/runtime" "^7.25.6" + gl-matrix "^3.4.3" + react-reconciler "^0.26.2" + scheduler "^0.20.2" + tslib "^2.5.3" + +"@antv/scale@^0.4.12", "@antv/scale@^0.4.16": + version "0.4.16" + resolved "https://registry.yarnpkg.com/@antv/scale/-/scale-0.4.16.tgz#60557470668ccfe5217e482a01f05c0cbb706b62" + integrity sha512-5wg/zB5kXHxpTV5OYwJD3ja6R8yTiqIOkjOhmpEJiowkzRlbEC/BOyMvNUq5fqFIHnMCE9woO7+c3zxEQCKPjw== + dependencies: + "@antv/util" "^3.3.7" + color-string "^1.5.5" + fecha "^4.2.1" + +"@antv/util@^2.0.13": + version "2.0.17" + resolved "https://registry.yarnpkg.com/@antv/util/-/util-2.0.17.tgz#e8ef42aca7892815b229269f3dd10c6b3c7597a9" + integrity sha512-o6I9hi5CIUvLGDhth0RxNSFDRwXeywmt6ExR4+RmVAzIi48ps6HUy+svxOCayvrPBN37uE6TAc2KDofRo0nK9Q== + dependencies: + csstype "^3.0.8" + tslib "^2.0.3" + +"@antv/util@^3.3.10", "@antv/util@^3.3.2", "@antv/util@^3.3.5", "@antv/util@^3.3.7": + version "3.3.10" + resolved "https://registry.yarnpkg.com/@antv/util/-/util-3.3.10.tgz#6fb2560c0f42df61f824e1f995a1ed1bdb00eb9a" + integrity sha512-basGML3DFA3O87INnzvDStjzS+n0JLEhRnRsDzP9keiXz8gT1z/fTdmJAZFOzMMWxy+HKbi7NbSt0+8vz/OsBQ== + dependencies: + fast-deep-equal "^3.1.3" + gl-matrix "^3.3.0" + tslib "^2.3.1" + +"@antv/vendor@1.0.6": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@antv/vendor/-/vendor-1.0.6.tgz#0148d7dbfdf06c43e063dab19f84409a8a79fef0" + integrity sha512-4WXnLPkbOip6b+dDTzDt5Flvoo7UG2xLTP6M3X3iinWfhKPFpuEVCM5ICXnl7KTTrTe796Uz4Jh7Gfq1DU8xLA== + dependencies: + "@types/d3-array" "3.0.5" + "@types/d3-color" "^3.1.3" + "@types/d3-dispatch" "^3.0.6" + "@types/d3-dsv" "^3.0.7" + "@types/d3-fetch" "^3.0.7" + "@types/d3-force" "^3.0.10" + "@types/d3-format" "^3.0.4" + "@types/d3-geo" "^3.1.0" + "@types/d3-hierarchy" "^3.1.7" + "@types/d3-interpolate" "^3.0.4" + "@types/d3-path" "^3.1.0" + "@types/d3-quadtree" "^3.0.6" + "@types/d3-random" "^3.0.3" + "@types/d3-scale-chromatic" "^3.1.0" + "@types/d3-shape" "^3.1.7" + "@types/d3-timer" "^3.0.2" + d3-array "^3.2.4" + d3-color "^3.1.0" + d3-dispatch "^3.0.1" + d3-dsv "^3.0.1" + d3-fetch "^3.0.1" + d3-force "^3.0.0" + d3-force-3d "^3.0.5" + d3-format "^3.1.0" + d3-geo "^3.1.1" + d3-geo-projection "^4.0.0" + d3-hierarchy "^3.1.2" + d3-interpolate "^3.0.1" + d3-path "^3.1.0" + d3-quadtree "^3.0.1" + d3-random "^3.0.1" + d3-regression "^1.3.10" + d3-scale-chromatic "^3.1.0" + d3-shape "^3.2.0" + d3-timer "^3.0.1" + +"@antv/vendor@^1.0.3": + version "1.0.10" + resolved "https://registry.yarnpkg.com/@antv/vendor/-/vendor-1.0.10.tgz#e0d7ab6ed25946f4b6ad1b5da481182639faea41" + integrity sha512-/llNfo0gyUAi+ZY3TAtkNPS66eXTMbNdaKd8qllyJUuXnpRHYd/LGU69ix6olGJEFBi61hO4f9eTY0zzNOlFlw== + dependencies: + "@types/d3-array" "^3.2.1" + "@types/d3-color" "^3.1.3" + "@types/d3-dispatch" "^3.0.6" + "@types/d3-dsv" "^3.0.7" + "@types/d3-fetch" "^3.0.7" + "@types/d3-force" "^3.0.10" + "@types/d3-format" "^3.0.4" + "@types/d3-geo" "^3.1.0" + "@types/d3-hierarchy" "^3.1.7" + "@types/d3-interpolate" "^3.0.4" + "@types/d3-path" "^3.1.0" + "@types/d3-quadtree" "^3.0.6" + "@types/d3-random" "^3.0.3" + "@types/d3-scale" "^4.0.9" + "@types/d3-scale-chromatic" "^3.1.0" + "@types/d3-shape" "^3.1.7" + "@types/d3-time" "^3.0.4" + "@types/d3-timer" "^3.0.2" + d3-array "^3.2.4" + d3-color "^3.1.0" + d3-dispatch "^3.0.1" + d3-dsv "^3.0.1" + d3-fetch "^3.0.1" + d3-force "^3.0.0" + d3-force-3d "^3.0.5" + d3-format "^3.1.0" + d3-geo "^3.1.1" + d3-geo-projection "^4.0.0" + d3-hierarchy "^3.1.2" + d3-interpolate "^3.0.1" + d3-path "^3.1.0" + d3-quadtree "^3.0.1" + d3-random "^3.0.1" + d3-regression "^1.3.10" + d3-scale "^4.0.2" + d3-scale-chromatic "^3.1.0" + d3-shape "^3.2.0" + d3-time "^3.1.0" + d3-timer "^3.0.1" + "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.16.0", "@babel/code-frame@^7.25.9", "@babel/code-frame@^7.26.0", "@babel/code-frame@^7.26.2", "@babel/code-frame@^7.8.3": version "7.26.2" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.26.2.tgz#4b5fab97d33338eff916235055f0ebc21e573a85" @@ -1120,6 +1695,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.25.6": + version "7.26.9" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.9.tgz#aa4c6facc65b9cb3f87d75125ffd47781b475433" + integrity sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.25.9.tgz#ecb62d81a8a6f5dc5fe8abfc3901fc52ddf15016" @@ -1477,6 +2059,11 @@ resolved "https://registry.yarnpkg.com/@csstools/utilities/-/utilities-2.0.0.tgz#f7ff0fee38c9ffb5646d47b6906e0bc8868bde60" integrity sha512-5VdOr0Z71u+Yp3ozOx8T11N703wIFGVRgOWbOZMKgglPJsWA54MRIoMNVMa7shUToIhx5J8vX4sOZgD2XiihiQ== +"@ctrl/tinycolor@^3.4.0": + version "3.6.1" + resolved "https://registry.yarnpkg.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz#b6c75a56a1947cc916ea058772d666a2c8932f31" + integrity sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA== + "@discoveryjs/json-ext@0.5.7": version "0.5.7" resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" @@ -2007,6 +2594,13 @@ resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.2.tgz#ff9221b9f58b4dfe61e619a7788734bd63f6898b" integrity sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g== +"@emotion/is-prop-valid@1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz#d4175076679c6a26faa92b03bb786f9e52612337" + integrity sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw== + dependencies: + "@emotion/memoize" "^0.8.1" + "@emotion/is-prop-valid@^1.3.0": version "1.3.1" resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz#8d5cf1132f836d7adbe42cf0b49df7816fc88240" @@ -2014,6 +2608,11 @@ dependencies: "@emotion/memoize" "^0.9.0" +"@emotion/memoize@^0.8.1": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.8.1.tgz#c1ddb040429c6d21d38cc945fe75c818cfb68e17" + integrity sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA== + "@emotion/memoize@^0.9.0": version "0.9.0" resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.9.0.tgz#745969d649977776b43fc7648c556aaa462b4102" @@ -2061,6 +2660,11 @@ "@emotion/use-insertion-effect-with-fallbacks" "^1.2.0" "@emotion/utils" "^1.4.2" +"@emotion/unitless@0.8.1": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.8.1.tgz#182b5a4704ef8ad91bde93f7a860a88fd92c79a3" + integrity sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ== + "@emotion/unitless@^0.10.0": version "0.10.0" resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.10.0.tgz#2af2f7c7e5150f497bdabd848ce7b218a27cf745" @@ -2218,6 +2822,20 @@ dependencies: "@lit-labs/ssr-dom-shim" "^1.2.0" +"@ljharb/resumer@~0.0.1": + version "0.0.1" + resolved "https://registry.yarnpkg.com/@ljharb/resumer/-/resumer-0.0.1.tgz#8a940a9192dd31f6a1df17564bbd26dc6ad3e68d" + integrity sha512-skQiAOrCfO7vRTq53cxznMpks7wS1va95UCidALlOVWqvBAzwPVErwizDwoMqNVMEn1mDq0utxZd02eIrvF1lw== + dependencies: + "@ljharb/through" "^2.3.9" + +"@ljharb/through@^2.3.9", "@ljharb/through@~2.3.9": + version "2.3.14" + resolved "https://registry.yarnpkg.com/@ljharb/through/-/through-2.3.14.tgz#a5df44295f44dc23bfe106af59426dd0677760b1" + integrity sha512-ajBvlKpWucBB17FuQYUShqpqy8GRgYEpJW0vWJbUu1CV9lWyrDCapy0lScU8T8Z6qn49sSwJB3+M+evYIdGg+A== + dependencies: + call-bind "^1.0.8" + "@mdx-js/mdx@^3.0.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@mdx-js/mdx/-/mdx-3.1.0.tgz#10235cab8ad7d356c262e8c21c68df5850a97dc3" @@ -2364,6 +2982,11 @@ prop-types "^15.8.1" react-is "^19.0.0" +"@naoak/workerize-transferable@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@naoak/workerize-transferable/-/workerize-transferable-0.1.0.tgz#864cc8241b977bffd8661c0be1441da9b4bfb633" + integrity sha512-fDLfuP71IPNP5+zSfxFb52OHgtjZvauRJWbVnpzQ7G7BjcbLjTny0OW1d3ZO806XKpLWNKmeeW3MhE0sy8iwYQ== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -2945,22 +3568,66 @@ dependencies: "@types/node" "*" -"@types/d3-array@^3.0.3": +"@types/d3-array@3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.0.5.tgz#857c1afffd3f51319bbc5b301956aca68acaa7b8" + integrity sha512-Qk7fpJ6qFp+26VeQ47WY0mkwXaiq8+76RJcncDEfMc2ocRzXLO67bLFRNI4OX1aGBoPzsM5Y2T+/m1pldOgD+A== + +"@types/d3-array@^3.0.3", "@types/d3-array@^3.2.1": version "3.2.1" resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.1.tgz#1f6658e3d2006c4fceac53fde464166859f8b8c5" integrity sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg== -"@types/d3-color@*": +"@types/d3-color@*", "@types/d3-color@^3.1.3": version "3.1.3" resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2" integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A== +"@types/d3-dispatch@^3.0.6": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz#096efdf55eb97480e3f5621ff9a8da552f0961e7" + integrity sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ== + +"@types/d3-dsv@*", "@types/d3-dsv@^3.0.7": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@types/d3-dsv/-/d3-dsv-3.0.7.tgz#0a351f996dc99b37f4fa58b492c2d1c04e3dac17" + integrity sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g== + "@types/d3-ease@^3.0.0": version "3.0.2" resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b" integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA== -"@types/d3-interpolate@^3.0.1": +"@types/d3-fetch@^3.0.7": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@types/d3-fetch/-/d3-fetch-3.0.7.tgz#c04a2b4f23181aa376f30af0283dbc7b3b569980" + integrity sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA== + dependencies: + "@types/d3-dsv" "*" + +"@types/d3-force@^3.0.10": + version "3.0.10" + resolved "https://registry.yarnpkg.com/@types/d3-force/-/d3-force-3.0.10.tgz#6dc8fc6e1f35704f3b057090beeeb7ac674bff1a" + integrity sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw== + +"@types/d3-format@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-format/-/d3-format-3.0.4.tgz#b1e4465644ddb3fdf3a263febb240a6cd616de90" + integrity sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g== + +"@types/d3-geo@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-geo/-/d3-geo-3.1.0.tgz#b9e56a079449174f0a2c8684a9a4df3f60522440" + integrity sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ== + dependencies: + "@types/geojson" "*" + +"@types/d3-hierarchy@^3.1.7": + version "3.1.7" + resolved "https://registry.yarnpkg.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz#6023fb3b2d463229f2d680f9ac4b47466f71f17b" + integrity sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg== + +"@types/d3-interpolate@^3.0.1", "@types/d3-interpolate@^3.0.4": version "3.0.4" resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c" integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA== @@ -2972,6 +3639,26 @@ resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.1.0.tgz#2b907adce762a78e98828f0b438eaca339ae410a" integrity sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ== +"@types/d3-path@^3.1.0": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.1.1.tgz#f632b380c3aca1dba8e34aa049bcd6a4af23df8a" + integrity sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg== + +"@types/d3-quadtree@^3.0.6": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz#d4740b0fe35b1c58b66e1488f4e7ed02952f570f" + integrity sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg== + +"@types/d3-random@^3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-random/-/d3-random-3.0.3.tgz#ed995c71ecb15e0cd31e22d9d5d23942e3300cfb" + integrity sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ== + +"@types/d3-scale-chromatic@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz#dc6d4f9a98376f18ea50bad6c39537f1b5463c39" + integrity sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ== + "@types/d3-scale@^4.0.2": version "4.0.8" resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.8.tgz#d409b5f9dcf63074464bf8ddfb8ee5a1f95945bb" @@ -2979,19 +3666,26 @@ dependencies: "@types/d3-time" "*" -"@types/d3-shape@^3.1.0": +"@types/d3-scale@^4.0.9": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.9.tgz#57a2f707242e6fe1de81ad7bfcccaaf606179afb" + integrity sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw== + dependencies: + "@types/d3-time" "*" + +"@types/d3-shape@^3.1.0", "@types/d3-shape@^3.1.7": version "3.1.7" resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.7.tgz#2b7b423dc2dfe69c8c93596e673e37443348c555" integrity sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg== dependencies: "@types/d3-path" "*" -"@types/d3-time@*", "@types/d3-time@^3.0.0": +"@types/d3-time@*", "@types/d3-time@^3.0.0", "@types/d3-time@^3.0.4": version "3.0.4" resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.4.tgz#8472feecd639691450dd8000eb33edd444e1323f" integrity sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g== -"@types/d3-timer@^3.0.0": +"@types/d3-timer@^3.0.0", "@types/d3-timer@^3.0.2": version "3.0.2" resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70" integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw== @@ -3071,6 +3765,11 @@ "@types/qs" "*" "@types/serve-static" "*" +"@types/geojson@*": + version "7946.0.16" + resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.16.tgz#8ebe53d69efada7044454e3305c19017d97ced2a" + integrity sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg== + "@types/gtag.js@^0.0.12": version "0.0.12" resolved "https://registry.yarnpkg.com/@types/gtag.js/-/gtag.js-0.0.12.tgz#095122edca896689bdfcdd73b057e23064d23572" @@ -3300,6 +3999,11 @@ dependencies: "@types/node" "*" +"@types/stylis@4.2.5": + version "4.2.5" + resolved "https://registry.yarnpkg.com/@types/stylis/-/stylis-4.2.5.tgz#1daa6456f40959d06157698a653a9ab0a70281df" + integrity sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw== + "@types/trusted-types@^2.0.2": version "2.0.7" resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" @@ -3334,6 +4038,16 @@ dependencies: "@types/yargs-parser" "*" +"@umijs/route-utils@^4.0.0": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@umijs/route-utils/-/route-utils-4.0.1.tgz#156df5b3f2328059722d3ee7dd8f65e18c3cde8b" + integrity sha512-+1ixf1BTOLuH+ORb4x8vYMPeIt38n9q0fJDwhv9nSxrV46mxbLF0nmELIo9CKQB2gHfuC4+hww6xejJ6VYnBHQ== + +"@umijs/use-params@^1.0.9": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@umijs/use-params/-/use-params-1.0.9.tgz#0ae4a87f4922d8e8e3fb4495b0f8f4de9ca38c52" + integrity sha512-QlN0RJSBVQBwLRNxbxjQ5qzqYIGn+K7USppMoIOVlf7fxXHsnQZ2bEsa6Pm74bt6DVQxpUE8HqvdStn6Y9FV1w== + "@ungap/structured-clone@^1.0.0": version "1.2.1" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.1.tgz#28fa185f67daaf7b7a1a8c1d445132c5d979f8bd" @@ -3578,6 +4292,20 @@ algoliasearch@^5.14.2, algoliasearch@^5.17.1: "@algolia/requester-fetch" "5.18.0" "@algolia/requester-node-http" "5.18.0" +align-text@^0.1.1, align-text@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117" + integrity sha512-GrTZLRpmp6wIC2ztrWW9MjjTgSKccffgFagbNDOX95/dcjEcYZibYTeaOntySQLcdw1ztBoFkviiUvTMbb9MYg== + dependencies: + kind-of "^3.0.2" + longest "^1.0.1" + repeat-string "^1.5.2" + +amdefine@>=0.0.4: + version "1.0.1" + resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" + integrity sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg== + ansi-align@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.1.tgz#0cdf12e111ace773a86e9a1fad1225c43cb19a59" @@ -3597,6 +4325,11 @@ ansi-html-community@^0.0.8: resolved "https://registry.yarnpkg.com/ansi-html-community/-/ansi-html-community-0.0.8.tgz#69fbc4d6ccbe383f9736934ae34c3f8290f1bf41" integrity sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw== +ansi-regex@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" + integrity sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA== + ansi-regex@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" @@ -3607,6 +4340,11 @@ ansi-regex@^6.0.1: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.1.0.tgz#95ec409c69619d6cb1b8b34f14b660ef28ebd654" integrity sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA== +ansi-styles@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" + integrity sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA== + ansi-styles@^4.0.0, ansi-styles@^4.1.0: version "4.3.0" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" @@ -3711,6 +4449,14 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +array-buffer-byte-length@^1.0.1, array-buffer-byte-length@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz#384d12a37295aec3769ab022ad323a18a51ccf8b" + integrity sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw== + dependencies: + call-bound "^1.0.3" + is-array-buffer "^3.0.5" + array-flatten@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" @@ -3721,11 +4467,29 @@ array-union@^2.1.0: resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== +arraybuffer.prototype.slice@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz#9d760d84dbdd06d0cbf92c8849615a1a7ab3183c" + integrity sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ== + dependencies: + array-buffer-byte-length "^1.0.1" + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + is-array-buffer "^3.0.4" + astring@^1.8.0: version "1.9.0" resolved "https://registry.yarnpkg.com/astring/-/astring-1.9.0.tgz#cc73e6062a7eb03e7d19c22d8b0b3451fd9bfeef" integrity sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg== +async-function@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/async-function/-/async-function-1.0.0.tgz#509c9fca60eaf85034c6829838188e4e4c8ffb2b" + integrity sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA== + async@^3.2.4: version "3.2.6" resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" @@ -3753,6 +4517,13 @@ autoprefixer@^10.4.19: picocolors "^1.0.1" postcss-value-parser "^4.2.0" +available-typed-arrays@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" + integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== + dependencies: + possible-typed-array-names "^1.0.0" + axios@^1.6.8: version "1.7.9" resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.9.tgz#d7d071380c132a24accda1b2cfc1535b79ec650a" @@ -3931,6 +4702,11 @@ browserslist@^4.0.0, browserslist@^4.18.1, browserslist@^4.23.0, browserslist@^4 node-releases "^2.0.19" update-browserslist-db "^1.1.1" +bubblesets-js@^2.3.4: + version "2.3.4" + resolved "https://registry.yarnpkg.com/bubblesets-js/-/bubblesets-js-2.3.4.tgz#8e1230b29c309e3327a05630fe02df3d96596ab6" + integrity sha512-DyMjHmpkS2+xcFNtyN00apJYL3ESdp9fTrkDr5+9Qg/GPqFmcWgGsK1akZnttE1XFxJ/VMy4DNNGMGYtmFp1Sg== + buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" @@ -3972,7 +4748,15 @@ call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1: es-errors "^1.3.0" function-bind "^1.1.2" -call-bind@^1.0.8: +call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + +call-bind@^1.0.2, call-bind@^1.0.7, call-bind@^1.0.8, call-bind@~1.0.2: version "1.0.8" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c" integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww== @@ -4003,6 +4787,11 @@ camel-case@^4.1.2: pascal-case "^3.1.2" tslib "^2.0.3" +camelcase@^1.0.2: + version "1.2.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-1.2.1.tgz#9bb5304d2e0b56698b2c758b08a3eaa9daa58a39" + integrity sha512-wzLkDa4K/mzI1OSITC+DUyjgIl/ETNHE9QvYgy6J6Jvqyyz4C0Xfd+lQhb19sX2jMpZV4IssUn0VDVmglV+s4g== + camelcase@^6.2.0: version "6.3.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" @@ -4013,6 +4802,11 @@ camelcase@^7.0.1: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-7.0.1.tgz#f02e50af9fd7782bc8b88a3558c32fd3a388f048" integrity sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw== +camelize@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.1.tgz#89b7e16884056331a35d6b5ad064332c91daa6c3" + integrity sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ== + caniuse-api@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0" @@ -4033,6 +4827,25 @@ ccount@^2.0.0: resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== +center-align@^0.1.1: + version "0.1.3" + resolved "https://registry.yarnpkg.com/center-align/-/center-align-0.1.3.tgz#aa0d32629b6ee972200411cbd4461c907bc2b7ad" + integrity sha512-Baz3aNe2gd2LP2qk5U+sDk/m4oSuwSDcBfayTCTBoWpfIGO5XFxPmjILQII4NGiZjD6DoDI6kf7gKaxkf7s3VQ== + dependencies: + align-text "^0.1.3" + lazy-cache "^1.0.3" + +chalk@^1.1.1: + version "1.1.3" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" + integrity sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A== + dependencies: + ansi-styles "^2.2.1" + escape-string-regexp "^1.0.2" + has-ansi "^2.0.0" + strip-ansi "^3.0.0" + supports-color "^2.0.0" + chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" @@ -4071,10 +4884,10 @@ character-reference-invalid@^2.0.0: resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz#85c66b041e43b47210faf401278abf808ac45cb9" integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw== -chart.js@4.4.6: - version "4.4.6" - resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.4.6.tgz#da39b84ca752298270d4c0519675c7659936abec" - integrity sha512-8Y406zevUPbbIBA/HRk33khEmQPk5+cxeflWE/2rx1NJsjVWMPw/9mSP9rxHP5eqi6LNoPBVMfZHxbwLSgldYA== +chart.js@4.4.8: + version "4.4.8" + resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.4.8.tgz#54645b638e9d585099bc16b892947b5e6cd2a552" + integrity sha512-IkGZlVpXP+83QpMm4uxEiGqSI7jFizwVtF3+n5Pc3k7sMO+tkd0qxh2OzLhenM0K80xtmAONWGBn082EiBQSDA== dependencies: "@kurkle/color" "^0.3.0" @@ -4159,6 +4972,15 @@ cli-table3@^0.6.3: optionalDependencies: "@colors/colors" "1.5.0" +cliui@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-2.1.0.tgz#4b475760ff80264c762c3a1719032e91c7fea0d1" + integrity sha512-GIOYRizG+TGoc7Wgc1LiOTLare95R3mzKgoln+Q/lE4ceiYH19gUpl0l0Ffq4lJDEf3FxujMe6IBfOCs7pfqNA== + dependencies: + center-align "^0.1.1" + right-align "^0.1.1" + wordwrap "0.0.2" + clone-deep@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387" @@ -4185,11 +5007,19 @@ color-convert@^2.0.1: dependencies: color-name "~1.1.4" -color-name@~1.1.4: +color-name@^1.0.0, color-name@~1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +color-string@^1.5.5: + version "1.9.1" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4" + integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + colord@^2.9.3: version "2.9.3" resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.3.tgz#4f8ce919de456f1d5c1c368c307fe20f3e59fb43" @@ -4212,6 +5042,11 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" +comlink@^4.4.1: + version "4.4.2" + resolved "https://registry.yarnpkg.com/comlink/-/comlink-4.4.2.tgz#cbbcd82742fbebc06489c28a183eedc5c60a2bca" + integrity sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g== + comma-separated-tokens@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee" @@ -4345,6 +5180,11 @@ content-type@~1.0.4, content-type@~1.0.5: resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== +contour_plot@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/contour_plot/-/contour_plot-0.0.1.tgz#475870f032b8e338412aa5fc507880f0bf495c77" + integrity sha512-Nil2HI76Xux6sVGORvhSS8v66m+/h5CwFkBJDO+U5vWaMdNC0yXNCsGDPbzPhvqOEU5koebhdEvD372LI+IyLw== + convert-source-map@^1.5.0: version "1.9.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" @@ -4466,6 +5306,11 @@ css-blank-pseudo@^7.0.1: dependencies: postcss-selector-parser "^7.0.0" +css-color-keywords@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05" + integrity sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg== + css-declaration-sorter@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-7.2.0.tgz#6dec1c9523bc4a643e088aab8f09e67a54961024" @@ -4533,6 +5378,15 @@ css-select@^5.1.0: domutils "^3.0.1" nth-check "^2.0.1" +css-to-react-native@3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/css-to-react-native/-/css-to-react-native-3.2.0.tgz#cdd8099f71024e149e4f6fe17a7d46ecd55f1e32" + integrity sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ== + dependencies: + camelize "^1.0.0" + css-color-keywords "^1.0.0" + postcss-value-parser "^4.0.2" + css-tree@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-2.3.1.tgz#10264ce1e5442e8572fc82fbe490644ff54b5c20" @@ -4633,12 +5487,12 @@ csso@^5.0.5: dependencies: css-tree "~2.2.0" -csstype@^3.0.2, csstype@^3.1.3: +csstype@3.1.3, csstype@^3.0.2, csstype@^3.0.8, csstype@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== -"d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@3, d3-array@^3.1.6, d3-array@^3.2.0: +"d3-array@1 - 3", "d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@3, d3-array@^3.1.6, d3-array@^3.2.0, d3-array@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5" integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg== @@ -4650,6 +5504,11 @@ d3-axis@3: resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-3.0.0.tgz#c42a4a13e8131d637b745fc2973824cfeaf93322" integrity sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw== +d3-binarytree@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/d3-binarytree/-/d3-binarytree-1.0.2.tgz#ed43ebc13c70fbabfdd62df17480bc5a425753cc" + integrity sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw== + d3-brush@3: version "3.0.0" resolved "https://registry.yarnpkg.com/d3-brush/-/d3-brush-3.0.0.tgz#6f767c4ed8dcb79de7ede3e1c0f89e63ef64d31c" @@ -4668,7 +5527,7 @@ d3-chord@3: dependencies: d3-path "1 - 3" -"d3-color@1 - 3", d3-color@3: +"d3-color@1 - 3", d3-color@3, d3-color@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== @@ -4687,7 +5546,7 @@ d3-delaunay@6: dependencies: delaunator "5" -"d3-dispatch@1 - 3", d3-dispatch@3: +"d3-dispatch@1 - 3", d3-dispatch@3, d3-dispatch@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e" integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg== @@ -4700,7 +5559,7 @@ d3-delaunay@6: d3-dispatch "1 - 3" d3-selection "3" -"d3-dsv@1 - 3", d3-dsv@3: +"d3-dsv@1 - 3", d3-dsv@3, d3-dsv@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/d3-dsv/-/d3-dsv-3.0.1.tgz#c63af978f4d6a0d084a52a673922be2160789b73" integrity sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q== @@ -4714,14 +5573,25 @@ d3-delaunay@6: resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4" integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w== -d3-fetch@3: +d3-fetch@3, d3-fetch@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/d3-fetch/-/d3-fetch-3.0.1.tgz#83141bff9856a0edb5e38de89cdcfe63d0a60a22" integrity sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw== dependencies: d3-dsv "1 - 3" -d3-force@3: +d3-force-3d@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/d3-force-3d/-/d3-force-3d-3.0.5.tgz#9c8931b49acc3554f9110e128bc580cd3ab830f2" + integrity sha512-tdwhAhoTYZY/a6eo9nR7HP3xSW/C6XvJTbeRpR92nlPzH6OiE+4MliN9feuSFd0tPtEUo+191qOhCTWx3NYifg== + dependencies: + d3-binarytree "1" + d3-dispatch "1 - 3" + d3-octree "1" + d3-quadtree "1 - 3" + d3-timer "1 - 3" + +d3-force@3, d3-force@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-3.0.0.tgz#3e2ba1a61e70888fe3d9194e30d6d14eece155c4" integrity sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg== @@ -4730,19 +5600,28 @@ d3-force@3: d3-quadtree "1 - 3" d3-timer "1 - 3" -"d3-format@1 - 3", d3-format@3: +"d3-format@1 - 3", d3-format@3, d3-format@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641" integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA== -d3-geo@3: +d3-geo-projection@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/d3-geo-projection/-/d3-geo-projection-4.0.0.tgz#dc229e5ead78d31869a4e87cf1f45bd2716c48ca" + integrity sha512-p0bK60CEzph1iqmnxut7d/1kyTmm3UWtPlwdkM31AU+LW+BXazd5zJdoCn7VFxNCHXRngPHRnsNn5uGjLRGndg== + dependencies: + commander "7" + d3-array "1 - 3" + d3-geo "1.12.0 - 3" + +"d3-geo@1.12.0 - 3", d3-geo@3, d3-geo@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/d3-geo/-/d3-geo-3.1.1.tgz#6027cf51246f9b2ebd64f99e01dc7c3364033a4d" integrity sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q== dependencies: d3-array "2.5.0 - 3" -d3-hierarchy@3: +d3-hierarchy@3, d3-hierarchy@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz#b01cd42c1eed3d46db77a5966cf726f8c09160c6" integrity sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA== @@ -4754,6 +5633,11 @@ d3-hierarchy@3: dependencies: d3-color "1 - 3" +d3-octree@1, d3-octree@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/d3-octree/-/d3-octree-1.1.0.tgz#f07e353b76df872644e7130ab1a74c5ef2f4287e" + integrity sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A== + "d3-path@1 - 3", d3-path@3, d3-path@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526" @@ -4764,17 +5648,22 @@ d3-polygon@3: resolved "https://registry.yarnpkg.com/d3-polygon/-/d3-polygon-3.0.1.tgz#0b45d3dd1c48a29c8e057e6135693ec80bf16398" integrity sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg== -"d3-quadtree@1 - 3", d3-quadtree@3: +"d3-quadtree@1 - 3", d3-quadtree@3, d3-quadtree@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz#6dca3e8be2b393c9a9d514dabbd80a92deef1a4f" integrity sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw== -d3-random@3: +d3-random@3, d3-random@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/d3-random/-/d3-random-3.0.1.tgz#d4926378d333d9c0bfd1e6fa0194d30aebaa20f4" integrity sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ== -d3-scale-chromatic@3: +d3-regression@^1.3.10: + version "1.3.10" + resolved "https://registry.yarnpkg.com/d3-regression/-/d3-regression-1.3.10.tgz#d1a411ab45044d9e8d5b8aec05f2e598e1a621c9" + integrity sha512-PF8GWEL70cHHWpx2jUQXc68r1pyPHIA+St16muk/XRokETzlegj5LriNKg7o4LR0TySug4nHYPJNNRz/W+/Niw== + +d3-scale-chromatic@3, d3-scale-chromatic@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz#34c39da298b23c20e02f1a4b239bd0f22e7f1314" integrity sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ== @@ -4798,7 +5687,7 @@ d3-scale@4, d3-scale@^4.0.2: resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31" integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ== -d3-shape@3, d3-shape@^3.1.0: +d3-shape@3, d3-shape@^3.1.0, d3-shape@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5" integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA== @@ -4812,7 +5701,7 @@ d3-shape@3, d3-shape@^3.1.0: dependencies: d3-time "1 - 3" -"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@3, d3-time@^3.0.0: +"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@3, d3-time@^3.0.0, d3-time@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7" integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q== @@ -4882,7 +5771,42 @@ d3@^7.9.0: d3-transition "3" d3-zoom "3" -dayjs@^1.11.11: +dagre@^0.8.5: + version "0.8.5" + resolved "https://registry.yarnpkg.com/dagre/-/dagre-0.8.5.tgz#ba30b0055dac12b6c1fcc247817442777d06afee" + integrity sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw== + dependencies: + graphlib "^2.1.8" + lodash "^4.17.15" + +data-view-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz#211a03ba95ecaf7798a8c7198d79536211f88570" + integrity sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-data-view "^1.0.2" + +data-view-byte-length@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz#9e80f7ca52453ce3e93d25a35318767ea7704735" + integrity sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-data-view "^1.0.2" + +data-view-byte-offset@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz#068307f9b71ab76dbbe10291389e020856606191" + integrity sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +dayjs@^1.11.10, dayjs@^1.11.11: version "1.11.13" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c" integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg== @@ -4906,6 +5830,11 @@ debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.5: dependencies: ms "^2.1.3" +decamelize@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== + decimal.js-light@^2.4.1: version "2.5.1" resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934" @@ -4930,6 +5859,18 @@ decompress-response@^6.0.0: dependencies: mimic-response "^3.1.0" +deep-equal@~1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.1.2.tgz#78a561b7830eef3134c7f6f3a3d6af272a678761" + integrity sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg== + dependencies: + is-arguments "^1.1.1" + is-date-object "^1.0.5" + is-regex "^1.1.4" + object-is "^1.1.5" + object-keys "^1.1.1" + regexp.prototype.flags "^1.5.1" + deep-extend@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" @@ -4952,7 +5893,7 @@ defer-to-connect@^2.0.1: resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587" integrity sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg== -define-data-property@^1.0.1, define-data-property@^1.1.4: +define-data-property@^1.0.1, define-data-property@^1.1.1, define-data-property@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== @@ -4975,6 +5916,11 @@ define-properties@^1.2.1: has-property-descriptors "^1.0.0" object-keys "^1.1.1" +defined@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.1.tgz#c0b9db27bfaffd95d6f61399419b893df0f91ebf" + integrity sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q== + del@^6.1.1: version "6.1.1" resolved "https://registry.yarnpkg.com/del/-/del-6.1.1.tgz#3b70314f1ec0aa325c6b14eb36b95786671edb7a" @@ -5011,7 +5957,7 @@ depd@~1.1.2: resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== -dequal@^2.0.0: +dequal@^2.0.0, dequal@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== @@ -5153,7 +6099,14 @@ dot-prop@^6.0.1: dependencies: is-obj "^2.0.0" -dunder-proto@^1.0.1: +dotignore@~0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/dotignore/-/dotignore-0.1.2.tgz#f942f2200d28c3a76fbdd6f0ee9f3257c8a2e905" + integrity sha512-UGGGWfSauusaVJC+8fgV+NVvBXkCTmVv7sk6nojDZZvuOUNGUy0Zk4UpHQD6EDjS0jpBwcACvH4eofvyzBcRDw== + dependencies: + minimatch "^3.0.4" + +dunder-proto@^1.0.0, dunder-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== @@ -5247,6 +6200,63 @@ error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" +es-abstract@^1.23.5, es-abstract@^1.23.9: + version "1.23.9" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.23.9.tgz#5b45994b7de78dada5c1bebf1379646b32b9d606" + integrity sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA== + dependencies: + array-buffer-byte-length "^1.0.2" + arraybuffer.prototype.slice "^1.0.4" + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.3" + data-view-buffer "^1.0.2" + data-view-byte-length "^1.0.2" + data-view-byte-offset "^1.0.1" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + es-set-tostringtag "^2.1.0" + es-to-primitive "^1.3.0" + function.prototype.name "^1.1.8" + get-intrinsic "^1.2.7" + get-proto "^1.0.0" + get-symbol-description "^1.1.0" + globalthis "^1.0.4" + gopd "^1.2.0" + has-property-descriptors "^1.0.2" + has-proto "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + internal-slot "^1.1.0" + is-array-buffer "^3.0.5" + is-callable "^1.2.7" + is-data-view "^1.0.2" + is-regex "^1.2.1" + is-shared-array-buffer "^1.0.4" + is-string "^1.1.1" + is-typed-array "^1.1.15" + is-weakref "^1.1.0" + math-intrinsics "^1.1.0" + object-inspect "^1.13.3" + object-keys "^1.1.1" + object.assign "^4.1.7" + own-keys "^1.0.1" + regexp.prototype.flags "^1.5.3" + safe-array-concat "^1.1.3" + safe-push-apply "^1.0.0" + safe-regex-test "^1.1.0" + set-proto "^1.0.0" + string.prototype.trim "^1.2.10" + string.prototype.trimend "^1.0.9" + string.prototype.trimstart "^1.0.8" + typed-array-buffer "^1.0.3" + typed-array-byte-length "^1.0.3" + typed-array-byte-offset "^1.0.4" + typed-array-length "^1.0.7" + unbox-primitive "^1.1.0" + which-typed-array "^1.1.18" + es-define-property@^1.0.0, es-define-property@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" @@ -5269,17 +6279,43 @@ es-object-atoms@^1.0.0: dependencies: es-errors "^1.3.0" -esast-util-from-estree@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz#8d1cfb51ad534d2f159dc250e604f3478a79f1ad" - integrity sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ== +es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== dependencies: - "@types/estree-jsx" "^1.0.0" - devlop "^1.0.0" - estree-util-visit "^2.0.0" - unist-util-position-from-estree "^2.0.0" + es-errors "^1.3.0" -esast-util-from-js@^2.0.0: +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +es-to-primitive@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.3.0.tgz#96c89c82cc49fd8794a24835ba3e1ff87f214e18" + integrity sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g== + dependencies: + is-callable "^1.2.7" + is-date-object "^1.0.5" + is-symbol "^1.0.4" + +esast-util-from-estree@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz#8d1cfb51ad534d2f159dc250e604f3478a79f1ad" + integrity sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ== + dependencies: + "@types/estree-jsx" "^1.0.0" + devlop "^1.0.0" + estree-util-visit "^2.0.0" + unist-util-position-from-estree "^2.0.0" + +esast-util-from-js@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/esast-util-from-js/-/esast-util-from-js-2.0.1.tgz#5147bec34cc9da44accf52f87f239a40ac3e8225" integrity sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw== @@ -5443,6 +6479,11 @@ eventemitter3@^4.0.0, eventemitter3@^4.0.1: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== +eventemitter3@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" + integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== + events@^3.2.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" @@ -5564,6 +6605,11 @@ faye-websocket@^0.11.3: dependencies: websocket-driver ">=0.5.1" +fecha@^4.2.1: + version "4.2.3" + resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.3.tgz#4d9ccdbc61e8629b259fdca67e65891448d569fd" + integrity sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw== + feed@^4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/feed/-/feed-4.2.2.tgz#865783ef6ed12579e2c44bbef3c9113bc4956a7e" @@ -5691,11 +6737,34 @@ flat@^5.0.2: resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== +flru@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/flru/-/flru-1.0.2.tgz#1ae514c62b8b035ffff9ca9e4563ddcc817f4845" + integrity sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog== + +fmin@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/fmin/-/fmin-0.0.2.tgz#59bbb40d43ffdc1c94cd00a568c41f95f1973017" + integrity sha512-sSi6DzInhl9d8yqssDfGZejChO8d2bAGIpysPsvYsxFe898z89XhCZg6CPNV3nhUhFefeC/AXZK2bAJxlBjN6A== + dependencies: + contour_plot "^0.0.1" + json2module "^0.0.3" + rollup "^0.25.8" + tape "^4.5.1" + uglify-js "^2.6.2" + follow-redirects@^1.0.0, follow-redirects@^1.15.6: version "1.15.9" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== +for-each@^0.3.3, for-each@~0.3.3: + version "0.3.5" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" + integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== + dependencies: + is-callable "^1.2.7" + fork-ts-checker-webpack-plugin@^6.5.0: version "6.5.3" resolved "https://registry.yarnpkg.com/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.3.tgz#eda2eff6e22476a2688d10661688c47f611b37f3" @@ -5802,6 +6871,23 @@ function-bind@^1.1.2: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== +function.prototype.name@^1.1.6, function.prototype.name@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz#e68e1df7b259a5c949eeef95cdbde53edffabb78" + integrity sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + functions-have-names "^1.2.3" + hasown "^2.0.2" + is-callable "^1.2.7" + +functions-have-names@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" + integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== + gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -5823,12 +6909,28 @@ get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6: hasown "^2.0.2" math-intrinsics "^1.1.0" +get-intrinsic@^1.2.7: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + get-own-enumerable-property-symbols@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664" integrity sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g== -get-proto@^1.0.0: +get-proto@^1.0.0, get-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== @@ -5841,6 +6943,15 @@ get-stream@^6.0.0, get-stream@^6.0.1: resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== +get-symbol-description@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.1.0.tgz#7bdd54e0befe8ffc9f3b4e203220d9f1e881b6ee" + integrity sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + gh-pages@^6.1.0: version "6.3.0" resolved "https://registry.yarnpkg.com/gh-pages/-/gh-pages-6.3.0.tgz#a5b9476dd4385ceaf85c6467b2e05397093e7613" @@ -5866,6 +6977,11 @@ github-slugger@^1.5.0: resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-1.5.0.tgz#17891bbc73232051474d68bd867a34625c955f7d" integrity sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw== +gl-matrix@^3.3.0, gl-matrix@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/gl-matrix/-/gl-matrix-3.4.3.tgz#fc1191e8320009fd4d20e9339595c6041ddc22c9" + integrity sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA== + glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" @@ -5885,7 +7001,7 @@ glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -glob@^7.0.0, glob@^7.1.6, glob@^9, glob@^9.2.0: +glob@^7.0.0, glob@^7.1.6, glob@^9, glob@^9.2.0, glob@~7.2.3: version "9.3.5" resolved "https://registry.yarnpkg.com/glob/-/glob-9.3.5.tgz#ca2ed8ca452781a3009685607fdf025a899dfe21" integrity sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q== @@ -5923,6 +7039,14 @@ globals@^11.1.0: resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== +globalthis@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" + integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== + dependencies: + define-properties "^1.2.1" + gopd "^1.0.1" + globby@^11.0.1, globby@^11.0.4, globby@^11.1.0: version "11.1.0" resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" @@ -5983,6 +7107,13 @@ graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11, resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725" integrity sha512-8tLu60LgxF6XpdbK8OW3FA+IfTNBn1ZHGHKF4KQbEeSkajYw5PlYJcKluntgegDPTg8UkHjpet1T82vk6TQ68w== +graphlib@^2.1.8: + version "2.1.8" + resolved "https://registry.yarnpkg.com/graphlib/-/graphlib-2.1.8.tgz#5761d414737870084c92ec7b5dbcb0592c9d35da" + integrity sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A== + dependencies: + lodash "^4.17.15" + gray-matter@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/gray-matter/-/gray-matter-4.0.3.tgz#e893c064825de73ea1f5f7d88c7a9f7274288798" @@ -6005,6 +7136,18 @@ handle-thing@^2.0.0: resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e" integrity sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg== +has-ansi@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" + integrity sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg== + dependencies: + ansi-regex "^2.0.0" + +has-bigints@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.1.0.tgz#28607e965ac967e03cd2a2c70a2636a1edad49fe" + integrity sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg== + has-flag@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" @@ -6017,17 +7160,36 @@ has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: dependencies: es-define-property "^1.0.0" -has-symbols@^1.1.0: +has-proto@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.2.0.tgz#5de5a6eabd95fdffd9818b43055e8065e39fe9d5" + integrity sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ== + dependencies: + dunder-proto "^1.0.0" + +has-symbols@^1.0.3, has-symbols@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== +has-tostringtag@^1.0.0, has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + has-yarn@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-3.0.0.tgz#c3c21e559730d1d3b57e28af1f30d06fac38147d" integrity sha512-IrsVwUHhEULx3R8f/aA8AHuEzAorplsab/v8HBzEiIukwq5i/EC+xmOW+HfP1OaDP+2JkgT1yILHN2O3UFIbcA== -hasown@^2.0.2: +has@~1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.4.tgz#2eb2860e000011dae4f1406a86fe80e530fb2ec6" + integrity sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ== + +hasown@^2.0.0, hasown@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== @@ -6378,6 +7540,11 @@ http2-wrapper@^2.1.10: quick-lru "^5.1.1" resolve-alpn "^1.2.0" +hull.js@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/hull.js/-/hull.js-1.0.6.tgz#75f013e8171eb9a871b4a94887e89eb555461d0e" + integrity sha512-TC7e9sHYOaCVms0sn2hN7buxnaGfcl9h5EPVoVX9DTPoMpqQiS9bf3tmGDgiNaMVHBD91RAvWjCxrJ5Jx8BI5A== + human-signals@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" @@ -6468,7 +7635,7 @@ inherits@2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw== -inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.3: +inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.3, inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -6488,6 +7655,15 @@ inline-style-parser@0.2.4: resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.2.4.tgz#f4af5fe72e612839fcd453d989a586566d695f22" integrity sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q== +internal-slot@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961" + integrity sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw== + dependencies: + es-errors "^1.3.0" + hasown "^2.0.2" + side-channel "^1.1.0" + "internmap@1 - 2": version "2.0.3" resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" @@ -6528,11 +7704,56 @@ is-alphanumerical@^2.0.0: is-alphabetical "^2.0.0" is-decimal "^2.0.0" +is-any-array@^2.0.0, is-any-array@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-any-array/-/is-any-array-2.0.1.tgz#9233242a9c098220290aa2ec28f82ca7fa79899e" + integrity sha512-UtilS7hLRu++wb/WBAw9bNuP1Eg04Ivn1vERJck8zJthEvXCBEBpGR/33u/xLKWEQf95803oalHrVDptcAvFdQ== + +is-arguments@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.2.0.tgz#ad58c6aecf563b78ef2bf04df540da8f5d7d8e1b" + integrity sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA== + dependencies: + call-bound "^1.0.2" + has-tostringtag "^1.0.2" + +is-array-buffer@^3.0.4, is-array-buffer@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz#65742e1e687bd2cc666253068fd8707fe4d44280" + integrity sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== +is-arrayish@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" + integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== + +is-async-function@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-async-function/-/is-async-function-2.1.1.tgz#3e69018c8e04e73b738793d020bfe884b9fd3523" + integrity sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ== + dependencies: + async-function "^1.0.0" + call-bound "^1.0.3" + get-proto "^1.0.1" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.0" + +is-bigint@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.1.0.tgz#dda7a3445df57a42583db4228682eba7c4170672" + integrity sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ== + dependencies: + has-bigints "^1.0.2" + is-binary-path@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" @@ -6540,6 +7761,24 @@ is-binary-path@~2.1.0: dependencies: binary-extensions "^2.0.0" +is-boolean-object@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz#7067f47709809a393c71ff5bb3e135d8a9215d9e" + integrity sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + +is-buffer@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== + +is-callable@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" + integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== + is-ci@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-3.0.1.tgz#db6ecbed1bd659c43dac0f45661e7674103d1867" @@ -6554,6 +7793,23 @@ is-core-module@^2.16.0: dependencies: hasown "^2.0.2" +is-data-view@^1.0.1, is-data-view@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.2.tgz#bae0a41b9688986c2188dda6657e56b8f9e63b8e" + integrity sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw== + dependencies: + call-bound "^1.0.2" + get-intrinsic "^1.2.6" + is-typed-array "^1.1.13" + +is-date-object@^1.0.5, is-date-object@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.1.0.tgz#ad85541996fc7aa8b2729701d27b7319f95d82f7" + integrity sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg== + dependencies: + call-bound "^1.0.2" + has-tostringtag "^1.0.2" + is-decimal@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-2.0.1.tgz#9469d2dc190d0214fd87d78b78caecc0cc14eef7" @@ -6574,11 +7830,28 @@ is-extglob@^2.1.1: resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== +is-finalizationregistry@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz#eefdcdc6c94ddd0674d9c85887bf93f944a97c90" + integrity sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg== + dependencies: + call-bound "^1.0.3" + is-fullwidth-code-point@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== +is-generator-function@^1.0.10: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.1.0.tgz#bf3eeda931201394f57b5dba2800f91a238309ca" + integrity sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ== + dependencies: + call-bound "^1.0.3" + get-proto "^1.0.0" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.0" + is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" @@ -6599,11 +7872,24 @@ is-installed-globally@^0.4.0: global-dirs "^3.0.0" is-path-inside "^3.0.2" +is-map@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" + integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== + is-npm@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-6.0.0.tgz#b59e75e8915543ca5d881ecff864077cba095261" integrity sha512-JEjxbSmtPSt1c8XTkVrlujcXdKV1/tvuQ7GwKcAlyiVLeYFQ2VHat8xfrDJsIkhCdF/tZ7CiIR3sy141c6+gPQ== +is-number-object@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.1.1.tgz#144b21e95a1bc148205dcc2814a9134ec41b2541" + integrity sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + is-number@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" @@ -6646,6 +7932,24 @@ is-plain-object@^2.0.4: dependencies: isobject "^3.0.1" +is-regex@^1.1.4, is-regex@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" + integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== + dependencies: + call-bound "^1.0.2" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +is-regex@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" + integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + is-regexp@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069" @@ -6656,16 +7960,72 @@ is-root@^2.1.0: resolved "https://registry.yarnpkg.com/is-root/-/is-root-2.1.0.tgz#809e18129cf1129644302a4f8544035d51984a9c" integrity sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg== +is-set@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" + integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg== + +is-shared-array-buffer@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz#9b67844bd9b7f246ba0708c3a93e34269c774f6f" + integrity sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A== + dependencies: + call-bound "^1.0.3" + is-stream@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== +is-string@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.1.1.tgz#92ea3f3d5c5b6e039ca8677e5ac8d07ea773cbb9" + integrity sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + +is-symbol@^1.0.4, is-symbol@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.1.1.tgz#f47761279f532e2b05a7024a7506dbbedacd0634" + integrity sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w== + dependencies: + call-bound "^1.0.2" + has-symbols "^1.1.0" + safe-regex-test "^1.1.0" + +is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15: + version "1.1.15" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b" + integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== + dependencies: + which-typed-array "^1.1.16" + is-typedarray@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== +is-weakmap@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd" + integrity sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w== + +is-weakref@^1.0.2, is-weakref@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.1.1.tgz#eea430182be8d64174bd96bffbc46f21bf3f9293" + integrity sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew== + dependencies: + call-bound "^1.0.3" + +is-weakset@^2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.4.tgz#c9f5deb0bc1906c6d6f1027f284ddf459249daca" + integrity sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ== + dependencies: + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + is-wsl@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" @@ -6683,6 +8043,11 @@ isarray@0.0.1: resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ== +isarray@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" + integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== + isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" @@ -6805,6 +8170,13 @@ json-schema-traverse@^1.0.0: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== +json2module@^0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/json2module/-/json2module-0.0.3.tgz#00fb5f4a9b7adfc3f0647c29cb17bcd1979be9b2" + integrity sha512-qYGxqrRrt4GbB8IEOy1jJGypkNsjWoIMlZt4bAsmUScCA507Hbc2p1JOhBzqn45u3PWafUgH2OnzyNU7udO/GA== + dependencies: + rw "^1.3.2" + json2mq@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/json2mq/-/json2mq-0.2.0.tgz#b637bd3ba9eabe122c83e9720483aeb10d2c904a" @@ -6840,6 +8212,13 @@ keyv@^4.5.3: dependencies: json-buffer "3.0.1" +kind-of@^3.0.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" + integrity sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ== + dependencies: + is-buffer "^1.1.5" + kind-of@^6.0.0, kind-of@^6.0.2: version "6.0.3" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" @@ -6865,6 +8244,11 @@ launch-editor@^2.6.0: picocolors "^1.0.0" shell-quote "^1.8.1" +lazy-cache@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" + integrity sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ== + leven@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" @@ -7021,6 +8405,11 @@ locate-path@^7.1.0: dependencies: p-locate "^6.0.0" +lodash-es@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" + integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" @@ -7036,7 +8425,7 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ== -lodash@^4.17.20, lodash@^4.17.21: +lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -7046,7 +8435,12 @@ longest-streak@^3.0.0: resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-3.1.0.tgz#62fa67cd958742a1574af9f39866364102d90cd4" integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g== -loose-envify@^1.0.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: +longest@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" + integrity sha512-k+yt5n3l48JU4k8ftnKG6V7u32wyH2NfKzeMto9F/QRE0amxy/LayxwlvjjkZEIzqR+19IrtFO8p5kB9QaYUFg== + +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -7911,7 +9305,7 @@ minimatch@^8.0.2: dependencies: brace-expansion "^2.0.1" -minimist@^1.2.0: +minimist@^1.2.0, minimist@~1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -7926,6 +9320,54 @@ minipass@^4.2.4: resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== +ml-array-max@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/ml-array-max/-/ml-array-max-1.2.4.tgz#2373e2b7e51c8807e456cc0ef364c5863713623b" + integrity sha512-BlEeg80jI0tW6WaPyGxf5Sa4sqvcyY6lbSn5Vcv44lp1I2GR6AWojfUvLnGTNsIXrZ8uqWmo8VcG1WpkI2ONMQ== + dependencies: + is-any-array "^2.0.0" + +ml-array-min@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/ml-array-min/-/ml-array-min-1.2.3.tgz#662f027c400105816b849cc3cd786915d0801495" + integrity sha512-VcZ5f3VZ1iihtrGvgfh/q0XlMobG6GQ8FsNyQXD3T+IlstDv85g8kfV0xUG1QPRO/t21aukaJowDzMTc7j5V6Q== + dependencies: + is-any-array "^2.0.0" + +ml-array-rescale@^1.3.7: + version "1.3.7" + resolved "https://registry.yarnpkg.com/ml-array-rescale/-/ml-array-rescale-1.3.7.tgz#c4d129320d113a732e62dd963dc1695bba9a5340" + integrity sha512-48NGChTouvEo9KBctDfHC3udWnQKNKEWN0ziELvY3KG25GR5cA8K8wNVzracsqSW1QEkAXjTNx+ycgAv06/1mQ== + dependencies: + is-any-array "^2.0.0" + ml-array-max "^1.2.4" + ml-array-min "^1.2.3" + +ml-matrix@^6.10.4: + version "6.12.0" + resolved "https://registry.yarnpkg.com/ml-matrix/-/ml-matrix-6.12.0.tgz#def6a0574b5fdc54a753033830e784a17399e270" + integrity sha512-AGfR+pWaC0GmzjUnB6BfwhndPEUGz0i7QUYdqNuw1zhTov/vSRJ9pP2hs6BoGpaSbtXgrKjZz2zjD1M0xuur6A== + dependencies: + is-any-array "^2.0.1" + ml-array-rescale "^1.3.7" + +mock-property@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/mock-property/-/mock-property-1.0.3.tgz#3e37c50a56609d548cabd56559fde3dd8767b10c" + integrity sha512-2emPTb1reeLLYwHxyVx993iYyCHEiRRO+y8NFXFPL5kl5q14sgTK76cXyEKkeKCHeRw35SfdkUJ10Q1KfHuiIQ== + dependencies: + define-data-property "^1.1.1" + functions-have-names "^1.2.3" + gopd "^1.0.1" + has-property-descriptors "^1.0.0" + hasown "^2.0.0" + isarray "^2.0.5" + +moment@^2.30.1: + version "2.30.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" + integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== + motion-dom@^12.4.5: version "12.4.5" resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-12.4.5.tgz#06116b5a091496654da0d67e40555abbd26068f6" @@ -8061,12 +9503,25 @@ object-inspect@^1.13.3: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.3.tgz#f14c183de51130243d6d18ae149375ff50ea488a" integrity sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA== +object-inspect@~1.12.3: + version "1.12.3" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9" + integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g== + +object-is@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.6.tgz#1a6a53aed2dd8f7e6775ff870bea58545956ab07" + integrity sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + object-keys@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== -object.assign@^4.1.0: +object.assign@^4.1.0, object.assign@^4.1.7: version "4.1.7" resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.7.tgz#8c14ca1a424c6a561b0bb2a22f66f5049a945d3d" integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw== @@ -8116,6 +9571,15 @@ opener@^1.5.2: resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A== +own-keys@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358" + integrity sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg== + dependencies: + get-intrinsic "^1.2.6" + object-keys "^1.1.1" + safe-push-apply "^1.0.0" + p-cancelable@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-3.0.0.tgz#63826694b54d61ca1c20ebcb6d3ecf5e14cd8050" @@ -8319,6 +9783,11 @@ path-to-regexp@3.3.0: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-3.3.0.tgz#f7f31d32e8518c2660862b644414b6d5c63a611b" integrity sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw== +path-to-regexp@8.2.0: + version "8.2.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.2.0.tgz#73990cc29e57a3ff2a0d914095156df5db79e8b4" + integrity sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ== + path-to-regexp@^1.7.0: version "1.9.0" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.9.0.tgz#5dc0753acbf8521ca2e0f137b4578b917b10cf24" @@ -8331,6 +9800,11 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +pdfast@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/pdfast/-/pdfast-0.2.0.tgz#8cbc556e1bf2522177787c0de2e0d4373ba885c9" + integrity sha512-cq6TTu6qKSFUHwEahi68k/kqN2mfepjkGrG9Un70cgdRRKLKY6Rf8P8uvP2NvZktaQZNF3YE7agEkLj0vGK9bA== + picocolors@^1.0.0, picocolors@^1.0.1, picocolors@^1.1.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" @@ -8362,6 +9836,11 @@ pkg-up@^3.1.0: dependencies: find-up "^3.0.0" +possible-typed-array-names@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae" + integrity sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg== + postcss-attribute-case-insensitive@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-7.0.1.tgz#0c4500e3bcb2141848e89382c05b5a31c23033a3" @@ -8899,7 +10378,7 @@ postcss-unique-selectors@^6.0.4: dependencies: postcss-selector-parser "^6.0.16" -postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: +postcss-value-parser@^4.0.2, postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== @@ -8909,7 +10388,7 @@ postcss-zindex@^6.0.2: resolved "https://registry.yarnpkg.com/postcss-zindex/-/postcss-zindex-6.0.2.tgz#e498304b83a8b165755f53db40e2ea65a99b56e1" integrity sha512-5BxW9l1evPB/4ZIc+2GobEBoKC+h8gPGCMi+jxsYvd2x0mjq7wazk6DrP71pStqxE9Foxh5TVnonbWpFZzXaYg== -postcss@^8.4.21, postcss@^8.4.24, postcss@^8.4.26, postcss@^8.4.33, postcss@^8.4.38: +postcss@8.4.49, postcss@^8.4.21, postcss@^8.4.24, postcss@^8.4.26, postcss@^8.4.33, postcss@^8.4.38: version "8.4.49" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.49.tgz#4ea479048ab059ab3ae61d082190fabfd994fe19" integrity sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA== @@ -9025,6 +10504,11 @@ quick-lru@^5.1.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== +quickselect@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/quickselect/-/quickselect-2.0.0.tgz#f19680a486a5eefb581303e023e98faaf25dd018" + integrity sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw== + randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -9052,6 +10536,13 @@ raw-body@2.5.2: iconv-lite "0.4.24" unpipe "1.0.0" +rbush@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/rbush/-/rbush-3.0.1.tgz#5fafa8a79b3b9afdfe5008403a720cc1de882ecf" + integrity sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w== + dependencies: + quickselect "^2.0.0" + rc-cascader@~3.32.0: version "3.32.0" resolved "https://registry.yarnpkg.com/rc-cascader/-/rc-cascader-3.32.0.tgz#75bb53b1592a7067a789122c026c48c9e863d531" @@ -9392,6 +10883,14 @@ rc-util@^5.0.1, rc-util@^5.16.1, rc-util@^5.17.0, rc-util@^5.18.1, rc-util@^5.2. "@babel/runtime" "^7.18.3" react-is "^18.2.0" +rc-util@^5.0.6, rc-util@^5.4.0: + version "5.44.4" + resolved "https://registry.yarnpkg.com/rc-util/-/rc-util-5.44.4.tgz#89ee9037683cca01cd60f1a6bbda761457dd6ba5" + integrity sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w== + dependencies: + "@babel/runtime" "^7.18.3" + react-is "^18.2.0" + rc-virtual-list@^3.14.2, rc-virtual-list@^3.5.1, rc-virtual-list@^3.5.2: version "3.17.0" resolved "https://registry.yarnpkg.com/rc-virtual-list/-/rc-virtual-list-3.17.0.tgz#669e29277040c20b6913bbb76f49ea0a92c1136f" @@ -9454,6 +10953,14 @@ react-dev-utils@^12.0.1: strip-ansi "^6.0.1" text-table "^0.2.0" +react-dom@^18.3.1: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" + integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== + dependencies: + loose-envify "^1.1.0" + scheduler "^0.23.2" + react-dom@^19.0.0: version "19.0.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.0.0.tgz#43446f1f01c65a4cd7f7588083e686a6726cfb57" @@ -9546,6 +11053,15 @@ react-masonry-css@^1.0.16: resolved "https://registry.yarnpkg.com/react-masonry-css/-/react-masonry-css-1.0.16.tgz#72b28b4ae3484e250534700860597553a10f1a2c" integrity sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ== +react-reconciler@^0.26.2: + version "0.26.2" + resolved "https://registry.yarnpkg.com/react-reconciler/-/react-reconciler-0.26.2.tgz#bbad0e2d1309423f76cf3c3309ac6c96e05e9d91" + integrity sha512-nK6kgY28HwrMNwDnMui3dvm3rCFjZrcGiuwLc5COUipBK5hWHLOxMJhSnSomirqWwjPBJKV1QcbkI0VJr7Gl1Q== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scheduler "^0.20.2" + react-router-config@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988" @@ -9600,6 +11116,13 @@ react-transition-group@^4.4.5: loose-envify "^1.4.0" prop-types "^15.6.2" +react@^18.3.1: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" + integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== + dependencies: + loose-envify "^1.1.0" + react@^19.0.0: version "19.0.0" resolved "https://registry.yarnpkg.com/react/-/react-19.0.0.tgz#6e1969251b9f108870aa4bff37a0ce9ddfaaabdd" @@ -9714,6 +11237,20 @@ recursive-readdir@^2.2.2: dependencies: minimatch "^3.0.5" +reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: + version "1.0.10" + resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz#c629219e78a3316d8b604c765ef68996964e7bf9" + integrity sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.9" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + get-intrinsic "^1.2.7" + get-proto "^1.0.1" + which-builtin-type "^1.2.1" + regenerate-unicode-properties@^10.2.0: version "10.2.0" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz#626e39df8c372338ea9b8028d1f99dc3fd9c3db0" @@ -9738,6 +11275,18 @@ regenerator-transform@^0.15.2: dependencies: "@babel/runtime" "^7.8.4" +regexp.prototype.flags@^1.5.1, regexp.prototype.flags@^1.5.3: + version "1.5.4" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz#1ad6c62d44a259007e55b3970e00f746efbcaa19" + integrity sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-errors "^1.3.0" + get-proto "^1.0.1" + gopd "^1.2.0" + set-function-name "^2.0.2" + regexpu-core@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-6.2.0.tgz#0e5190d79e542bf294955dccabae04d3c7d53826" @@ -9914,7 +11463,7 @@ renderkid@^3.0.0: lodash "^4.17.21" strip-ansi "^6.0.1" -repeat-string@^1.0.0: +repeat-string@^1.0.0, repeat-string@^1.5.2: version "1.6.1" resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" integrity sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w== @@ -9954,7 +11503,7 @@ resolve-pathname@^3.0.0: resolved "https://registry.yarnpkg.com/resolve-pathname/-/resolve-pathname-3.0.0.tgz#99d02224d3cf263689becbb393bc560313025dcd" integrity sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng== -resolve@^1.1.6, resolve@^1.14.2, resolve@^1.19.0: +resolve@^1.1.6, resolve@^1.14.2, resolve@^1.19.0, resolve@~1.22.6: version "1.22.10" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== @@ -9980,6 +11529,13 @@ reusify@^1.0.4: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== +right-align@^0.1.1: + version "0.1.3" + resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef" + integrity sha512-yqINtL/G7vs2v+dFIZmFUDbnVyFUJFKd6gK22Kgo6R4jfJGFtisKyncWDDULgjfqf4ASQuIQyjJ7XZ+3aWpsAg== + dependencies: + align-text "^0.1.1" + rimraf@^3.0.2, rimraf@^4: version "4.4.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-4.4.1.tgz#bd33364f67021c5b79e93d7f4fa0568c7c21b755" @@ -9992,6 +11548,15 @@ robust-predicates@^3.0.2: resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771" integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg== +rollup@^0.25.8: + version "0.25.8" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-0.25.8.tgz#bf6ce83b87510d163446eeaa577ed6a6fc5835e0" + integrity sha512-a2S4Bh3bgrdO4BhKr2E4nZkjTvrJ2m2bWjMTzVYtoqSCn0HnuxosXnaJUHrMEziOWr3CzL9GjilQQKcyCQpJoA== + dependencies: + chalk "^1.1.1" + minimist "^1.2.0" + source-map-support "^0.3.2" + rtlcss@^4.1.0: version "4.3.0" resolved "https://registry.yarnpkg.com/rtlcss/-/rtlcss-4.3.0.tgz#f8efd4d5b64f640ec4af8fa25b65bacd9e07cc97" @@ -10009,11 +11574,22 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -rw@1: +rw@1, rw@^1.3.2: version "1.3.3" resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4" integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ== +safe-array-concat@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz#c9e54ec4f603b0bbb8e7e5007a5ee7aecd1538c3" + integrity sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + get-intrinsic "^1.2.6" + has-symbols "^1.1.0" + isarray "^2.0.5" + safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.1.0, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" @@ -10024,6 +11600,28 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== +safe-push-apply@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz#01850e981c1602d398c85081f360e4e6d03d27f5" + integrity sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA== + dependencies: + es-errors "^1.3.0" + isarray "^2.0.5" + +safe-regex-test@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz#7f87dfb67a3150782eaaf18583ff5d1711ac10c1" + integrity sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-regex "^1.2.1" + +safe-stable-stringify@^2.4.3: + version "2.5.0" + resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd" + integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA== + "safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" @@ -10034,6 +11632,21 @@ sax@^1.2.4: resolved "https://registry.yarnpkg.com/sax/-/sax-1.4.1.tgz#44cc8988377f126304d3b3fc1010c733b929ef0f" integrity sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg== +scheduler@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91" + integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + +scheduler@^0.23.2: + version "0.23.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" + integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== + dependencies: + loose-envify "^1.1.0" + scheduler@^0.25.0: version "0.25.0" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.25.0.tgz#336cd9768e8cceebf52d3c80e3dcf5de23e7e015" @@ -10196,6 +11809,25 @@ set-function-length@^1.2.2: gopd "^1.0.1" has-property-descriptors "^1.0.2" +set-function-name@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" + integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + functions-have-names "^1.2.3" + has-property-descriptors "^1.0.2" + +set-proto@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/set-proto/-/set-proto-1.0.0.tgz#0760dbcff30b2d7e801fd6e19983e56da337565e" + integrity sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw== + dependencies: + dunder-proto "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + setprototypeof@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656" @@ -10213,7 +11845,7 @@ shallow-clone@^3.0.0: dependencies: kind-of "^6.0.2" -shallowequal@^1.1.0: +shallowequal@1.1.0, shallowequal@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== @@ -10273,7 +11905,7 @@ side-channel-weakmap@^1.0.2: object-inspect "^1.13.3" side-channel-map "^1.0.1" -side-channel@^1.0.6: +side-channel@^1.0.6, side-channel@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== @@ -10298,6 +11930,13 @@ simple-git@^3.27.0: "@kwsites/promise-deferred" "^1.1.1" debug "^4.3.5" +simple-swizzle@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" + integrity sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg== + dependencies: + is-arrayish "^0.3.1" + sirv@^2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/sirv/-/sirv-2.0.4.tgz#5dd9a725c578e34e449f332703eb2a74e46a29b0" @@ -10366,6 +12005,13 @@ source-map-js@^1.0.1, source-map-js@^1.2.1: resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== +source-map-support@^0.3.2: + version "0.3.3" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.3.3.tgz#34900977d5ba3f07c7757ee72e73bb1a9b53754f" + integrity sha512-9O4+y9n64RewmFoKUZ/5Tx9IHIcXM6Q+RTSw6ehnqybUz4a7iwR3Eaw80uLtqqQ5D0C+5H03D4KKGo9PdP33Gg== + dependencies: + source-map "0.1.32" + source-map-support@~0.5.20: version "0.5.21" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" @@ -10374,7 +12020,14 @@ source-map-support@~0.5.20: buffer-from "^1.0.0" source-map "^0.6.0" -source-map@^0.5.7: +source-map@0.1.32: + version "0.1.32" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.1.32.tgz#c8b6c167797ba4740a8ea33252162ff08591b266" + integrity sha512-htQyLrrRLkQ87Zfrir4/yN+vAUd6DNjVayEjTSHXu29AYQJw57I4/xEL/M6p6E/woPNJwvZt6rVlzc7gFEJccQ== + dependencies: + amdefine ">=0.0.4" + +source-map@^0.5.7, source-map@~0.5.1: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ== @@ -10465,6 +12118,38 @@ string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" +string.prototype.trim@^1.2.10, string.prototype.trim@~1.2.8: + version "1.2.10" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz#40b2dd5ee94c959b4dcfb1d65ce72e90da480c81" + integrity sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + define-data-property "^1.1.4" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-object-atoms "^1.0.0" + has-property-descriptors "^1.0.2" + +string.prototype.trimend@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz#62e2731272cd285041b36596054e9f66569b6942" + integrity sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +string.prototype.trimstart@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz#7ee834dda8c7c17eff3118472bb35bfedaa34dde" + integrity sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + string_decoder@^1.1.1: version "1.3.0" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" @@ -10496,6 +12181,13 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" +strip-ansi@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" + integrity sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg== + dependencies: + ansi-regex "^2.0.0" + strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" @@ -10544,6 +12236,21 @@ style-to-object@^1.0.0: dependencies: inline-style-parser "0.2.4" +styled-components@^6.1.13: + version "6.1.15" + resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-6.1.15.tgz#7651904d5424d08c1046056eb39024cc23c72ab7" + integrity sha512-PpOTEztW87Ua2xbmLa7yssjNyUF9vE7wdldRfn1I2E6RTkqknkBYpj771OxM/xrvRGinLy2oysa7GOd7NcZZIA== + dependencies: + "@emotion/is-prop-valid" "1.2.2" + "@emotion/unitless" "0.8.1" + "@types/stylis" "4.2.5" + css-to-react-native "3.2.0" + csstype "3.1.3" + postcss "8.4.49" + shallowequal "1.1.0" + stylis "4.3.2" + tslib "2.6.2" + stylehacks@^6.1.1: version "6.1.1" resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-6.1.1.tgz#543f91c10d17d00a440430362d419f79c25545a6" @@ -10557,11 +12264,21 @@ stylis@4.2.0: resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.2.0.tgz#79daee0208964c8fe695a42fcffcac633a211a51" integrity sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw== +stylis@4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.3.2.tgz#8f76b70777dd53eb669c6f58c997bf0a9972e444" + integrity sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg== + stylis@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.3.4.tgz#ca5c6c4a35c4784e4e93a2a24dc4e9fa075250a4" integrity sha512-osIBl6BGUmSfDkyH2mB7EFvCJntXDrLhKjHTRj/rK6xLH0yuPrHULDRQzKokSOD4VoorhtKpfcfW1GAntu8now== +supports-color@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" + integrity sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g== + supports-color@^7.1.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" @@ -10586,6 +12303,11 @@ svg-parser@^2.0.4: resolved "https://registry.yarnpkg.com/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5" integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ== +svg-path-parser@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/svg-path-parser/-/svg-path-parser-1.1.0.tgz#e16b4b39df0d2b0d39e8347db79fdda1453a6046" + integrity sha512-jGCUqcQyXpfe38R7RFfhrMyfXcBmpMNJI/B+4CE9/Unkh98UporAc461GTthv+TVDuZXsBx7/WiwJb1Oh4tt4A== + svgo@^3.0.2, svgo@^3.2.0: version "3.3.2" resolved "https://registry.yarnpkg.com/svgo/-/svgo-3.3.2.tgz#ad58002652dffbb5986fc9716afe52d869ecbda8" @@ -10606,6 +12328,14 @@ swc-loader@^0.2.6: dependencies: "@swc/counter" "^0.1.3" +swr@^2.0.0: + version "2.3.2" + resolved "https://registry.yarnpkg.com/swr/-/swr-2.3.2.tgz#e7c4eb7115e7ff734e445ad0036e97812dd95191" + integrity sha512-RosxFpiabojs75IwQ316DGoDRmOqtiAj0tg8wCcbEu4CiLZBs/a9QNtHV7TUfDXmmlgqij/NqzKq/eLelyv9xA== + dependencies: + dequal "^2.0.3" + use-sync-external-store "^1.4.0" + tapable@^1.0.0: version "1.1.3" resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" @@ -10616,6 +12346,28 @@ tapable@^2.0.0, tapable@^2.1.1, tapable@^2.2.0, tapable@^2.2.1: resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== +tape@^4.5.1: + version "4.17.0" + resolved "https://registry.yarnpkg.com/tape/-/tape-4.17.0.tgz#de89f3671ddc5dad178d04c28dc6b0183f42268e" + integrity sha512-KCuXjYxCZ3ru40dmND+oCLsXyuA8hoseu2SS404Px5ouyS0A99v8X/mdiLqsR5MTAyamMBN7PRwt2Dv3+xGIxw== + dependencies: + "@ljharb/resumer" "~0.0.1" + "@ljharb/through" "~2.3.9" + call-bind "~1.0.2" + deep-equal "~1.1.1" + defined "~1.0.1" + dotignore "~0.1.2" + for-each "~0.3.3" + glob "~7.2.3" + has "~1.0.3" + inherits "~2.0.4" + is-regex "~1.1.4" + minimist "~1.2.8" + mock-property "~1.0.0" + object-inspect "~1.12.3" + resolve "~1.22.6" + string.prototype.trim "~1.2.8" + terser-webpack-plugin@^5.3.10, terser-webpack-plugin@^5.3.9: version "5.3.11" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.11.tgz#93c21f44ca86634257cac176f884f942b7ba3832" @@ -10716,7 +12468,12 @@ trough@^2.0.0: resolved "https://registry.yarnpkg.com/trough/-/trough-2.2.0.tgz#94a60bd6bd375c152c1df911a4b11d5b0256f50f" integrity sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw== -tslib@^2.0.0, tslib@^2.0.3, tslib@^2.4.0, tslib@^2.6.0: +tslib@2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" + integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== + +tslib@^2.0.0, tslib@^2.0.3, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.5.0, tslib@^2.5.3, tslib@^2.6.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -10744,6 +12501,51 @@ type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" +typed-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz#a72395450a4869ec033fd549371b47af3a2ee536" + integrity sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-typed-array "^1.1.14" + +typed-array-byte-length@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz#8407a04f7d78684f3d252aa1a143d2b77b4160ce" + integrity sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg== + dependencies: + call-bind "^1.0.8" + for-each "^0.3.3" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.14" + +typed-array-byte-offset@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz#ae3698b8ec91a8ab945016108aef00d5bff12355" + integrity sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + for-each "^0.3.3" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.15" + reflect.getprototypeof "^1.0.9" + +typed-array-length@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.7.tgz#ee4deff984b64be1e118b0de8c9c877d5ce73d3d" + integrity sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + is-typed-array "^1.1.13" + possible-typed-array-names "^1.0.0" + reflect.getprototypeof "^1.0.6" + typed-function@^4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/typed-function/-/typed-function-4.2.1.tgz#19aa51847aa2dea9ef5e7fb7641c060179a74426" @@ -10761,6 +12563,31 @@ typescript@^5.6.3: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.2.tgz#3169cf8c4c8a828cde53ba9ecb3d2b1d5dd67be6" integrity sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg== +uglify-js@^2.6.2: + version "2.8.29" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.29.tgz#29c5733148057bb4e1f75df35b7a9cb72e6a59dd" + integrity sha512-qLq/4y2pjcU3vhlhseXGGJ7VbFO4pBANu0kwl8VCa9KEI0V8VfZIx2Fy3w01iSTA/pGwKZSmu/+I4etLNDdt5w== + dependencies: + source-map "~0.5.1" + yargs "~3.10.0" + optionalDependencies: + uglify-to-browserify "~1.0.0" + +uglify-to-browserify@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7" + integrity sha512-vb2s1lYx2xBtUgy+ta+b2J/GLVUR+wmpINwHePmPRhOsIVCG2wDzKJ0n14GslH1BifsqVzSOwQhRaCAsZ/nI4Q== + +unbox-primitive@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz#8d9d2c9edeea8460c7f35033a88867944934d1e2" + integrity sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw== + dependencies: + call-bound "^1.0.3" + has-bigints "^1.0.2" + has-symbols "^1.1.0" + which-boxed-primitive "^1.1.1" + undici-types@~6.20.0: version "6.20.0" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" @@ -10929,6 +12756,11 @@ url-loader@^4.1.1: mime-types "^2.1.27" schema-utils "^3.0.0" +use-sync-external-store@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz#adbc795d8eeb47029963016cefdf89dc799fcebc" + integrity sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw== + util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -11013,6 +12845,13 @@ void-elements@3.1.0: resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09" integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w== +warning@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" + integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w== + dependencies: + loose-envify "^1.0.0" + watchpack@^2.4.1: version "2.4.2" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.2.tgz#2feeaed67412e7c33184e5a79ca738fbd38564da" @@ -11178,6 +13017,58 @@ websocket-extensions@>=0.1.1: resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== +which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz#d76ec27df7fa165f18d5808374a5fe23c29b176e" + integrity sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA== + dependencies: + is-bigint "^1.1.0" + is-boolean-object "^1.2.1" + is-number-object "^1.1.1" + is-string "^1.1.1" + is-symbol "^1.1.1" + +which-builtin-type@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/which-builtin-type/-/which-builtin-type-1.2.1.tgz#89183da1b4907ab089a6b02029cc5d8d6574270e" + integrity sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q== + dependencies: + call-bound "^1.0.2" + function.prototype.name "^1.1.6" + has-tostringtag "^1.0.2" + is-async-function "^2.0.0" + is-date-object "^1.1.0" + is-finalizationregistry "^1.1.0" + is-generator-function "^1.0.10" + is-regex "^1.2.1" + is-weakref "^1.0.2" + isarray "^2.0.5" + which-boxed-primitive "^1.1.0" + which-collection "^1.0.2" + which-typed-array "^1.1.16" + +which-collection@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.2.tgz#627ef76243920a107e7ce8e96191debe4b16c2a0" + integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw== + dependencies: + is-map "^2.0.3" + is-set "^2.0.3" + is-weakmap "^2.0.2" + is-weakset "^2.0.3" + +which-typed-array@^1.1.16, which-typed-array@^1.1.18: + version "1.1.18" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.18.tgz#df2389ebf3fbb246a71390e90730a9edb6ce17ad" + integrity sha512-qEcY+KJYlWyLH9vNbsr6/5j59AXk5ni5aakf8ldzBvGde6Iz4sxZGkJyWSAueTG7QhOvNRYb1lDdFmL5Td0QKA== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.3" + for-each "^0.3.3" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + which@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" @@ -11204,6 +13095,16 @@ wildcard@^2.0.0, wildcard@^2.0.1: resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.1.tgz#5ab10d02487198954836b6349f74fff961e10f67" integrity sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ== +window-size@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d" + integrity sha512-1pTPQDKTdd61ozlKGNCjhNRd+KPmgLSGa3mZTHoOliaGcESD8G1PXhh7c1fgiPjVbNVfgy2Faw4BI8/m0cC8Mg== + +wordwrap@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f" + integrity sha512-xSBsCeh+g+dinoBv3GAOWM4LcVVO68wLXRanibtBSdUvkGWQRGeE9P7IwU9EmDDi4jA6L44lz15CGMwdw9N5+Q== + wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" @@ -11264,6 +13165,16 @@ yaml@^1.10.0, yaml@^1.7.2: resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== +yargs@~3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.10.0.tgz#f7ee7bd857dd7c1d2d38c0e74efbd681d1431fd1" + integrity sha512-QFzUah88GAGy9lyDKGBqZdkYApt63rCXYBGYnEP4xDJPXNqXXnBDACnbrXnViV6jRSqAePwrATi2i8mfYm4L1A== + dependencies: + camelcase "^1.0.2" + cliui "^2.1.0" + decamelize "^1.0.0" + window-size "0.1.0" + yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" From 17829f63b260a237f9f2060eba4a6f70e73e8168 Mon Sep 17 00:00:00 2001 From: zephyr-sh Date: Mon, 3 Mar 2025 22:01:23 +0800 Subject: [PATCH 02/25] [C] Update dashboard --- package.json | 3 +- src/components/AuthModal.js | 87 ++-- src/components/Dashboard/ApiKey/index.js | 395 ++++++++++++++ .../ApiUsage}/index.js | 0 src/components/Dashboard/MyComments/index.js | 259 +++++++++ .../MyInfo}/index.js | 0 .../dashboard/DashboardApiKey/index.js | 50 -- .../dashboard/DashboardMyComments/index.js | 139 ----- src/components/forms/LoginForm.js | 156 ++++-- src/components/forms/RegisterForm.js | 231 +++++++- src/context/AuthContext.js | 94 +++- src/hooks/useAuthHandler.js | 119 ++++- src/pages/dashboard.js | 12 +- src/theme/Navbar/Content/index.js | 54 +- src/utils/mockApi.js | 493 +++++++++++++++--- yarn.lock | 5 + 16 files changed, 1641 insertions(+), 456 deletions(-) create mode 100644 src/components/Dashboard/ApiKey/index.js rename src/components/{dashboard/DashboardApiUsage => Dashboard/ApiUsage}/index.js (100%) create mode 100644 src/components/Dashboard/MyComments/index.js rename src/components/{dashboard/DashboardMyInfo => Dashboard/MyInfo}/index.js (100%) delete mode 100644 src/components/dashboard/DashboardApiKey/index.js delete mode 100644 src/components/dashboard/DashboardMyComments/index.js diff --git a/package.json b/package.json index 3d41d81b576..76071c9ecea 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,8 @@ "remark-math": "6", "search-insights": "^1", "simple-git": "^3.27.0", - "typescript": "^5.6.3" + "typescript": "^5.6.3", + "zxcvbn": "^4.4.2" }, "devDependencies": { "@docusaurus/module-type-aliases": "^3.7.0", diff --git a/src/components/AuthModal.js b/src/components/AuthModal.js index e60e007b626..d39694e1448 100644 --- a/src/components/AuthModal.js +++ b/src/components/AuthModal.js @@ -1,5 +1,5 @@ -import { FacebookOutlined, GoogleOutlined } from "@ant-design/icons"; -import { Button, Divider, Modal, Space, Tabs, Typography } from "antd"; +// /src/components/AuthModal.js +import { Divider, Modal, Tabs, Typography } from "antd"; import React, { useState } from "react"; import useAuthHandler from "../hooks/useAuthHandler"; import ForgotPasswordForm from "./forms/ForgotPasswordForm"; @@ -7,74 +7,52 @@ import LoginForm from "./forms/LoginForm"; import RegisterForm from "./forms/RegisterForm"; export default function AuthModal({ visible, onCancel }) { - // 用同一個 hook => 只管理登入 / 註冊 / 社群登入的 loading - const { login, register, socialLogin, loading } = useAuthHandler(); - const [activeKey, setActiveKey] = useState("login"); + // 1. 從自訂 Hook 取得登入 / 註冊 函式與 loading 狀態 + const { login, register, loading } = useAuthHandler(); - // 這裡額外用一個 state 記錄當前模式 => "login" / "register" / "forgotPassword" + // 2. 目前顯示的分頁 (login / register / forgotPassword) const [mode, setMode] = useState("login"); + // 3. 切換畫面 const goToForgotPassword = () => setMode("forgotPassword"); const goToLogin = () => setMode("login"); const goToRegister = () => setMode("register"); - // 社群登入按鈕 - const socialButtons = ( - - - - - ); - - // 對應登入 / 註冊 UI + // 4. 「登入」畫面內容 const renderLoginContent = () => ( - <> - - 或使用以下帳號登入 - {socialButtons} - + ); + // 5. 「註冊」畫面內容 const renderRegisterContent = () => ( - <> - - 或使用以下帳號註冊 / 登入 - {socialButtons} - + ); - // 忘記密碼畫面只需要一個 Email => 發送重設信 + // 6. 「忘記密碼」畫面內容 const renderForgotPasswordContent = () => ( <> - + 回到登入 ); + // 7. 依照 mode 顯示不同內容 const renderContent = () => { switch (mode) { case "login": @@ -94,11 +72,10 @@ export default function AuthModal({ visible, onCancel }) { title="會員中心" onCancel={onCancel} footer={null} - destroyOnClose + // 注意:如果想保留密碼輸入狀態,可移除 destroyOnClose + // destroyOnClose > - {/* 這裡若想維持 Tabs,也可以把三種狀態都做成 Tabs */} - {/* 不過常見做法是 forgotPassword 就脫離 Tabs,獨立顯示 */} - + {/* 如果當前是忘記密碼模式,就脫離 Tabs,獨立顯示 */} {mode === "forgotPassword" ? ( renderContent() ) : ( @@ -109,13 +86,13 @@ export default function AuthModal({ visible, onCancel }) { { key: "login", label: "登入", - children: renderLoginContent() + children: renderLoginContent(), }, { key: "register", label: "註冊", - children: renderRegisterContent() - } + children: renderRegisterContent(), + }, ]} /> )} diff --git a/src/components/Dashboard/ApiKey/index.js b/src/components/Dashboard/ApiKey/index.js new file mode 100644 index 00000000000..92e014c5dc2 --- /dev/null +++ b/src/components/Dashboard/ApiKey/index.js @@ -0,0 +1,395 @@ +// 檔案: src/components/dashboard/DashboardApiKey/index.js + +import { EyeInvisibleOutlined, EyeOutlined } from "@ant-design/icons"; +import { + Button, + Card, + Col, + Drawer, + Form, + Input, + message, + Modal, + Popconfirm, + Progress, + Row, + Space, + Switch, + Table, + Tag, +} from "antd"; +import moment from "moment"; +import React, { useEffect, useState } from "react"; +import { + createApiKeyApi, + deleteApiKeyApi, + getApiKeyUsageApi, + getMyApiKeysApi, + regenerateApiKeyApi, + updateApiKeyNameApi, +} from "../../../utils/mockApi"; + +export default function DashboardApiKey() { + const [loading, setLoading] = useState(false); + const [apiKeys, setApiKeys] = useState([]); + const [createModalVisible, setCreateModalVisible] = useState(false); + + // Drawer 狀態:顯示單一 API Key 詳細資訊 + const [detailDrawerVisible, setDetailDrawerVisible] = useState(false); + const [detailLoading, setDetailLoading] = useState(false); + const [detailData, setDetailData] = useState(null); + + // 「顯示 / 隱藏」 Key 內容 + const [showKeys, setShowKeys] = useState({}); + // 例如 { keyId: true/false } + + // 建立新的 Key 表單 + const [form] = Form.useForm(); + + useEffect(() => { + fetchApiKeys(); + }, []); + + const fetchApiKeys = async () => { + setLoading(true); + try { + const data = await getMyApiKeysApi(); + setApiKeys(data); + } catch (err) { + message.error(err.message || "取得 API Key 失敗"); + } finally { + setLoading(false); + } + }; + + // Modal:建立新的 API Key + const handleCreateKey = async (values) => { + try { + const newKeyObj = await createApiKeyApi(values.name); + message.success("已成功建立新 API Key"); + setApiKeys((prev) => [...prev, newKeyObj]); + setCreateModalVisible(false); + form.resetFields(); + } catch (err) { + message.error(err.message || "建立失敗"); + } + }; + + // 「刪除」某把 Key + const handleDeleteKey = async (record) => { + try { + await deleteApiKeyApi(record.id); + message.success(`已刪除 API Key: ${record.name}`); + setApiKeys((prev) => prev.filter((k) => k.id !== record.id)); + } catch (err) { + message.error(err.message || "刪除失敗"); + } + }; + + // 「重新生成」某把 Key + const handleRegenerateKey = async (record) => { + try { + const newKeyStr = await regenerateApiKeyApi(record.id); + message.success("已重新生成 API Key"); + // 更新該 Key 的 keyString + setApiKeys((prev) => + prev.map((item) => + item.id === record.id ? { ...item, keyString: newKeyStr } : item + ) + ); + } catch (err) { + message.error(err.message || "重新生成失敗"); + } + }; + + // 「重新命名」Key + const handleRenameKey = async (record, newName) => { + try { + await updateApiKeyNameApi(record.id, newName); + message.success("名稱已更新"); + setApiKeys((prev) => + prev.map((item) => + item.id === record.id ? { ...item, name: newName } : item + ) + ); + } catch (err) { + message.error(err.message || "更新失敗"); + } + }; + + // 打開 Drawer,顯示更詳細資訊 (例如:用量, IP白名單, 失效日期, etc.) + const openDetailDrawer = async (record) => { + setDetailDrawerVisible(true); + setDetailLoading(true); + try { + // 可能要呼叫後端取得更完整資訊 (例如 getApiKeyUsageApi) + const usageData = await getApiKeyUsageApi(record.id); + setDetailData({ + ...record, + usage: usageData.usage, + limit: usageData.limit, + whitelist: usageData.ipWhitelist || [], + }); + } catch (err) { + message.error("取得詳細資料失敗:" + err.message); + } finally { + setDetailLoading(false); + } + }; + + // 顯示/隱藏某把 Key + const toggleShowKey = (record) => { + setShowKeys((prev) => ({ + ...prev, + [record.id]: !prev[record.id], + })); + }; + + // Table欄位定義 + const columns = [ + { + title: "名稱", + dataIndex: "name", + render: (text, record) => ( + handleRenameKey(record, newVal)} + /> + ), + }, + { + title: "API Key", + dataIndex: "keyString", + render: (text, record) => { + // 顯示 / 隱藏 Key + const showing = showKeys[record.id]; + // 若不顯示則遮蔽中間段 + const masked = text + ? showing + ? text + : maskKeyString(text) + : "N/A"; + return ( + + {masked} + + + + + handleDeleteKey(record)} + > + + + + ), + }, + ]; + + return ( + + +
+ + + + + + + +
+ + {/* 建立新 API Key 的 Modal */} + { + setCreateModalVisible(false); + form.resetFields(); + }} + onOk={() => form.submit()} + okText="建立" + cancelText="取消" + destroyOnClose + > +
+ + + + +
+ + {/* 詳細資訊 Drawer */} + setDetailDrawerVisible(false)} + > + {detailLoading ? ( +

載入中...

+ ) : detailData ? ( + + ) : ( +

無法載入資料

+ )} +
+ + ); +} + +/** + * Drawer 內容 + * 這裡可以顯示更深入資訊,如「用量統計」、「IP 白名單」、「是否啟用 / 停用」等。 + */ +function DetailContent({ detailData }) { + const { usage = 0, limit = 1000, whitelist = [] } = detailData; + + const usagePercent = Math.min(100, Math.round((usage / limit) * 100)); + + return ( +
+

顯示更多資訊,例如 IP 白名單、用量概覽等。

+ +
目前用量: + + +
+ {usage} / {limit} 次呼叫 +
+ + + + +
IP 白名單: + + {whitelist.length === 0 ? ( + 未設定 + ) : ( + whitelist.map((ip) => {ip}) + )} + + + + + 啟用狀態: + + ...} // 可在此呼叫後端 API 更新狀態 + /> + + + + ); +} + +/** + * 編輯 API Key 名稱的小組件:點擊文字 -> 變成可輸入 + */ +function EditableText({ text, onSave }) { + const [editing, setEditing] = useState(false); + const [val, setVal] = useState(text); + + const handleSubmit = () => { + if (val.trim() && val !== text) { + onSave(val.trim()); + } + setEditing(false); + }; + + if (editing) { + return ( + setVal(e.target.value)} + onBlur={handleSubmit} + onPressEnter={handleSubmit} + style={{ width: 150 }} + /> + ); + } + return setEditing(true)} style={{ cursor: "pointer" }}>{text}; +} + +/** + * 將字串中段以 `*` 隱藏,例如: ABCDEFG123 => AB****3123 + */ +function maskKeyString(keyString = "") { + if (keyString.length < 8) { + return "****"; + } + const prefix = keyString.slice(0, 2); + const suffix = keyString.slice(-4); + return prefix + "****" + suffix; +} diff --git a/src/components/dashboard/DashboardApiUsage/index.js b/src/components/Dashboard/ApiUsage/index.js similarity index 100% rename from src/components/dashboard/DashboardApiUsage/index.js rename to src/components/Dashboard/ApiUsage/index.js diff --git a/src/components/Dashboard/MyComments/index.js b/src/components/Dashboard/MyComments/index.js new file mode 100644 index 00000000000..082647b6c51 --- /dev/null +++ b/src/components/Dashboard/MyComments/index.js @@ -0,0 +1,259 @@ +// 檔案: src/components/dashboard/DashboardMyComments.jsx +import { + Button, + Card, + Col, + DatePicker, + Form, + Input, + message, + Modal, + Row, + Space, + Spin, + Table, +} from "antd"; +import moment from "moment"; +import React, { useEffect, useState } from "react"; +import { + deleteCommentApi, + getMyCommentsApi, + updateCommentApi, +} from "../../../utils/mockApi"; + +const { RangePicker } = DatePicker; + +export default function DashboardMyComments() { + const [loading, setLoading] = useState(false); + const [comments, setComments] = useState([]); + + // 搜尋關鍵字 + const [searchText, setSearchText] = useState(""); + // 建立日期篩選 (start ~ end) + const [dateRange, setDateRange] = useState([]); + // 批次刪除多選 + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + + // 編輯留言 Modal + const [editModalVisible, setEditModalVisible] = useState(false); + const [editingComment, setEditingComment] = useState(null); + + // 初次載入 or 重新整理 → 取得留言 + useEffect(() => { + fetchComments(); + }, []); + + const fetchComments = async () => { + setLoading(true); + try { + const data = await getMyCommentsApi(); + setComments(data); + } catch (err) { + message.error(err.message || "取得留言失敗"); + } finally { + setLoading(false); + } + }; + + // 單筆:開啟「編輯 Modal」 + const handleEdit = (record) => { + setEditingComment({ ...record }); + setEditModalVisible(true); + }; + + // 單筆:執行刪除 + const handleDelete = async (id) => { + try { + await deleteCommentApi(id); + message.success("留言已刪除"); + setComments((prev) => prev.filter((item) => item.id !== id)); + } catch (err) { + message.error(err.message || "刪除失敗"); + } + }; + + // 編輯後:儲存變更 + const handleSaveComment = async (values) => { + try { + await updateCommentApi(values.id, values.content); + message.success("留言已更新"); + setComments((prev) => + prev.map((c) => (c.id === values.id ? { ...c, ...values } : c)) + ); + setEditModalVisible(false); + } catch (err) { + message.error(err.message || "更新失敗"); + } + }; + + // 批次刪除 + const handleBatchDelete = async () => { + if (selectedRowKeys.length === 0) return; + try { + for (let commentId of selectedRowKeys) { + await deleteCommentApi(commentId); + } + message.success("批次刪除成功"); + setComments((prev) => prev.filter((c) => !selectedRowKeys.includes(c.id))); + setSelectedRowKeys([]); + } catch (err) { + message.error("批次刪除失敗:" + err.message); + } + }; + + // Table columns + const columns = [ + { title: "ID", dataIndex: "id", width: 80 }, + { + title: "留言內容", + dataIndex: "content", + sorter: (a, b) => a.content.localeCompare(b.content), + render: (text) => {text}, + }, + { + title: "建立日期", + dataIndex: "createdAt", + width: 180, + sorter: (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + }, + { + title: "操作", + width: 120, + render: (_, record) => ( + <> + + + + ), + }, + ]; + + // 多選設定 + const rowSelection = { + selectedRowKeys, + onChange: (keys) => setSelectedRowKeys(keys), + }; + + // 前端篩選:依「關鍵字 + 日期區間」 + const filteredComments = comments.filter((item) => { + // 1) 關鍵字 + const matchSearch = item.content + .toLowerCase() + .includes(searchText.toLowerCase()); + + // 2) 日期 + let matchDate = true; + if (dateRange?.[0] && dateRange?.[1]) { + const start = dateRange[0].startOf("day"); + const end = dateRange[1].endOf("day"); + const created = moment(item.createdAt, "YYYY-MM-DD HH:mm"); + matchDate = created.isBetween(start, end, null, "[]"); + } + return matchSearch && matchDate; + }); + + return ( + +

我的留言

+ + {/* 若正在 loading,顯示 Spin */} + {loading ? ( + + ) : ( + + {/* 搜尋、日期篩選、批次刪除、重新整理 */} +
+ setSearchText(e.target.value)} + /> + + + + setDateRange(dates || [])} + /> + + + + + + )} + + {/* 主表格 */} +
+ + {/* 編輯留言 Modal */} + setEditModalVisible(false)} + onSave={handleSaveComment} + /> + + ); +} + +/** 編輯留言彈窗 */ +function EditCommentModal({ visible, comment, onCancel, onSave }) { + const [form] = Form.useForm(); + + useEffect(() => { + if (comment) { + form.setFieldsValue(comment); + } else { + form.resetFields(); + } + }, [comment, form]); + + const handleFinish = (values) => { + onSave(values); + }; + + return ( + form.submit()} + okText="儲存" + cancelText="取消" + destroyOnClose + > +
+ + + + + +
+ ); +} diff --git a/src/components/dashboard/DashboardMyInfo/index.js b/src/components/Dashboard/MyInfo/index.js similarity index 100% rename from src/components/dashboard/DashboardMyInfo/index.js rename to src/components/Dashboard/MyInfo/index.js diff --git a/src/components/dashboard/DashboardApiKey/index.js b/src/components/dashboard/DashboardApiKey/index.js deleted file mode 100644 index da2090eed15..00000000000 --- a/src/components/dashboard/DashboardApiKey/index.js +++ /dev/null @@ -1,50 +0,0 @@ -import { Button, Card, message } from "antd"; -import React, { useEffect, useState } from "react"; -import { getMyApiKeyApi, regenerateApiKeyApi } from "../../../utils/mockApi"; - -export default function DashboardApiKey() { - const [loading, setLoading] = useState(false); - const [apiKey, setApiKey] = useState(""); - - const fetchApiKey = async () => { - setLoading(true); - try { - const key = await getMyApiKeyApi(); - setApiKey(key); - } catch (err) { - message.error(err.message || "取得 API Key 失敗"); - } finally { - setLoading(false); - } - }; - - const regenerateKey = async () => { - setLoading(true); - try { - const newKey = await regenerateApiKeyApi(); - setApiKey(newKey); - message.success("已重新生成 API Key"); - } catch (err) { - message.error(err.message || "重新生成失敗"); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchApiKey(); - }, []); - - return ( - -

我的 API Key

-

目前的 API Key:

-
-        {apiKey || "N/A"}
-      
- -
- ); -} diff --git a/src/components/dashboard/DashboardMyComments/index.js b/src/components/dashboard/DashboardMyComments/index.js deleted file mode 100644 index 5bf0e2c27d0..00000000000 --- a/src/components/dashboard/DashboardMyComments/index.js +++ /dev/null @@ -1,139 +0,0 @@ -// src/components/dashboard/DashboardMyComments.jsx -import { Button, Form, Input, message, Modal, Table } from "antd"; -import React, { useEffect, useState } from "react"; -import { deleteCommentApi, getMyCommentsApi, updateCommentApi } from "../../../utils/mockApi"; - -export default function DashboardMyComments() { - const [loading, setLoading] = useState(false); - const [comments, setComments] = useState([]); - const [editingComment, setEditingComment] = useState(null); - const [editModalVisible, setEditModalVisible] = useState(false); - - useEffect(() => { - fetchComments(); - }, []); - - const fetchComments = async () => { - setLoading(true); - try { - // 呼叫後端抓此用戶的所有留言 - const data = await getMyCommentsApi(); - setComments(data); - } catch (err) { - message.error(err.message || "取得留言失敗"); - } finally { - setLoading(false); - } - }; - - const handleEdit = (record) => { - setEditingComment({ ...record }); - setEditModalVisible(true); - }; - - const handleDelete = async (id) => { - try { - await deleteCommentApi(id); - message.success("留言已刪除"); - setComments((prev) => prev.filter((c) => c.id !== id)); - } catch (err) { - message.error(err.message || "刪除失敗"); - } - }; - - const handleSaveComment = async (values) => { - try { - await updateCommentApi(values.id, values.content); - message.success("留言已更新"); - setComments((prev) => - prev.map((c) => (c.id === values.id ? { ...c, ...values } : c)) - ); - setEditModalVisible(false); - } catch (err) { - message.error(err.message || "更新失敗"); - } - }; - - const columns = [ - { title: "ID", dataIndex: "id", width: 80 }, - { - title: "留言內容", - dataIndex: "content", - render: (text) => <>{text}, - }, - { - title: "建立日期", - dataIndex: "createdAt", - width: 160, - }, - { - title: "操作", - render: (text, record) => ( - <> - - - - ), - }, - ]; - - return ( -
-

我的留言

-
- - setEditModalVisible(false)} - onSave={handleSaveComment} - /> - - ); -} - -function EditCommentModal({ visible, onCancel, comment, onSave }) { - const [form] = Form.useForm(); - - useEffect(() => { - if (comment) { - form.setFieldsValue(comment); - } - }, [comment]); - - const onFinish = (values) => { - onSave(values); - }; - - return ( - form.submit()} - > -
- - - - - -
- ); -} diff --git a/src/components/forms/LoginForm.js b/src/components/forms/LoginForm.js index f9bea5d51e4..dad93defeac 100644 --- a/src/components/forms/LoginForm.js +++ b/src/components/forms/LoginForm.js @@ -1,72 +1,140 @@ -// components/forms/LoginForm.js -import { Button, Form, Input, Typography } from "antd"; -import React from "react"; +// /components/forms/LoginForm.js +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; +import { Alert, Button, Form, Input, Typography } from "antd"; +import React, { useState } from "react"; -const weakPasswords = [ - "123456", "password", "123456789", "12345678", "12345", "1234567", "qwerty", - "abc123", "password1", "111111", "123123", "admin", "welcome", "iloveyou", - "1q2w3e4r", "monkey", "sunshine", "letmein", "football", "dragon", "shadow", - "1234", "princess", "baseball", "superman", "starwars" -]; -export default function LoginForm({ onLogin, onSuccess, loading }) { +const localeText = { + "zh-hant": { + usernameLabel: "帳號", + usernameError: "請輸入帳號", + passwordLabel: "密碼", + passwordRequired: "請輸入密碼", + loginBtn: "登入", + forgotPassword: "忘記密碼?", + loginSuccessMsg: "登入成功!", + }, + en: { + usernameLabel: "Username", + usernameError: "Please enter your username", + passwordLabel: "Password", + passwordRequired: "Please enter your password", + loginBtn: "Login", + forgotPassword: "Forgot password?", + loginSuccessMsg: "Login successful!", + }, + ja: { + usernameLabel: "ユーザー名", + usernameError: "ユーザー名を入力してください", + passwordLabel: "パスワード", + passwordRequired: "パスワードを入力してください", + loginBtn: "ログイン", + forgotPassword: "パスワードをお忘れですか?", + loginSuccessMsg: "ログイン成功!", + }, +}; + +export default function LoginForm({ + onLogin, // 呼叫後端 /auth/login 的函式 + onSuccess, // 登入成功後的 callback (可用來跳轉 / 關閉 Modal) + loading, // 登入按鈕的加載狀態 + onToggleForgotPassword // 切換到「忘記密碼」畫面的函式 (若需要) +}) { + const { + i18n: { currentLocale }, + } = useDocusaurusContext(); + const text = localeText[currentLocale] || localeText.en; + + // 顯示錯誤與成功提示 + const [submitError, setSubmitError] = useState(""); + const [successMessage, setSuccessMessage] = useState(""); + + // antd form + const [form] = Form.useForm(); + + /** + * 提交表單: + * 1. 清空錯誤、成功提示 + * 2. 呼叫 onLogin(username, password) + * 3. 若成功 => 顯示成功訊息 / 呼叫 onSuccess + * 4. 若失敗 => 顯示錯誤訊息 + */ const onFinish = async (values) => { + setSubmitError(""); + setSuccessMessage(""); + const ok = await onLogin(values.username, values.password); if (ok) { - onSuccess?.(); + // 登入成功 + setSuccessMessage(text.loginSuccessMsg); + onSuccess?.(); // 若需要跳轉 => 在父層做 window.location.href 或關閉 Modal + } else { + // 登入失敗 + setSubmitError("帳號或密碼錯誤,請再試一次。"); } }; return ( -
+ + {/* 帳號欄位 */} + {/* 密碼欄位 */} ({ - validator(_, value) { - if (weakPasswords.includes(value?.toLowerCase())) { - return Promise.reject( - new Error("此密碼過於常見,請使用更安全的密碼") - ); - } - return Promise.resolve(); - }, - }), - ]} + rules={[{ required: true, message: text.passwordRequired }]} > + {/* 登入成功訊息 */} + {successMessage && ( + + )} + + {/* 錯誤訊息 */} + {submitError && ( + + )} + + {/* 提交按鈕 */} - { - // 假設要在同一個 Modal 內顯示 reset flow - // 可透過 props 或 context 切換狀態 - onToggleForgotPassword?.(); - }} - > - 忘記密碼? - + + {/* 忘記密碼連結 */} + {onToggleForgotPassword && ( + + {text.forgotPassword} + + )} ); } diff --git a/src/components/forms/RegisterForm.js b/src/components/forms/RegisterForm.js index e82510f8405..ec23b3f1f60 100644 --- a/src/components/forms/RegisterForm.js +++ b/src/components/forms/RegisterForm.js @@ -1,43 +1,232 @@ -// components/forms/RegisterForm.js -import { Button, Form, Input } from "antd"; -import React from "react"; +// /components/forms/RegisterForm.js +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; +import { Alert, Button, Form, Input, Progress } from "antd"; +import React, { useState } from "react"; +import zxcvbn from "zxcvbn"; +import { useAuth } from "../../context/AuthContext"; -export default function RegisterForm({ onRegister, onSuccess, loading }) { +function getPasswordScore(password) { + if (!password) return 0; + const result = zxcvbn(password); + return result.score; // 0~4 +} + +// 多國語系 +const localeText = { + "zh-hant": { + usernameLabel: "帳號", + usernameRequired: "請輸入帳號", + passwordLabel: "密碼", + passwordRequired: "請輸入密碼", + registerBtn: "註冊", + passphraseHint: "建議使用可記憶的長密碼(如短語)提升安全性。", + successMsg: "註冊成功!", + pwnedWarning: "此密碼曾出現在外洩紀錄中,請使用更安全的密碼。", + passwordTooShort: "密碼長度需至少 8 碼", + passwordStrengthTitle: "密碼強度:", + strengthTexts: ["非常弱", "弱", "中等", "強", "非常強"], + }, + en: { + usernameLabel: "Username", + usernameRequired: "Please enter your username", + passwordLabel: "Password", + passwordRequired: "Please enter your password", + registerBtn: "Register", + passphraseHint: "Consider using a memorable passphrase for better security.", + successMsg: "Registration successful!", + pwnedWarning: "This password has appeared in data breaches. Please use a more secure one.", + passwordTooShort: "Password must be at least 8 characters", + passwordStrengthTitle: "Password Strength: ", + strengthTexts: ["Very Weak", "Weak", "Medium", "Strong", "Very Strong"], + }, + ja: { + usernameLabel: "ユーザー名", + usernameRequired: "ユーザー名を入力してください", + passwordLabel: "パスワード", + passwordRequired: "パスワードを入力してください", + registerBtn: "登録", + passphraseHint: "覚えやすいパスフレーズを使用してセキュリティを向上させましょう。", + successMsg: "登録が完了しました!", + pwnedWarning: "このパスワードは漏洩した履歴があります。より安全なものを使用してください。", + passwordTooShort: "パスワードは8文字以上である必要があります", + passwordStrengthTitle: "パスワードの強度:", + strengthTexts: ["非常に弱い", "弱い", "普通", "強い", "非常に強い"], + }, +}; + +export default function RegisterForm({ + onLogin, + onSuccess, + onRegister, + loading +}) { + const { + i18n: { currentLocale }, + } = useDocusaurusContext(); + const text = localeText[currentLocale] || localeText.en; + + // 如果後端回 token,可配合 AuthContext 寫入 localStorage + const { loginSuccess } = useAuth(); + + // antd form + const [form] = Form.useForm(); + + // 狀態 + const [submitError, setSubmitError] = useState(""); + const [successMessage, setSuccessMessage] = useState(""); + + // 用來顯示即時強度 + const [passwordScore, setPasswordScore] = useState(0); + + /** + * 表單送出 + * 不允許弱密碼 => 只要後端回 pwned=true => fail => 顯示錯誤 + */ const onFinish = async (values) => { - const ok = await onRegister(values.username, values.password); - if (ok) { - onSuccess?.(); + setSubmitError(""); + setSuccessMessage(""); + const result = await onRegister({ + username: values.username, + password: values.password, + }); + + if (!result) { + setSubmitError("Unknown error"); + return; + } + + if (result.success) { + finishRegisterSuccess(result); + const ok = await onLogin(values.username, values.password); + if (ok) { + onSuccess?.(); + } + window.location.href = "/dashboard"; + } else { + // 若 pwned=true => 後端要求不允許 => 顯示錯誤 + if (result.pwned) { + setSubmitError(text.pwnedWarning); + } else { + // 其他錯誤 (ex: 帳號重複) + setSubmitError(result.error || "Registration failed"); + } } }; + /** + * 註冊成功 => 重置表單 + (若需要) loginSuccess(token) + 跳轉 + */ + const finishRegisterSuccess = (result) => { + setSuccessMessage(text.successMsg); + form.resetFields(); + setPasswordScore(0); + + // 若後端回 token => 自動登入 + if (result.token) { + loginSuccess(result.token); + } + }; + + // 基本規則: 必填 & >=8 chars + const passwordRules = [ + { + required: true, + message: text.passwordRequired, + }, + { + validator: async (_, value) => { + if (!value) { + return Promise.reject(new Error(text.passwordRequired)); + } + if (value.length < 8) { + return Promise.reject(new Error(text.passwordTooShort)); + } + return Promise.resolve(); + }, + }, + ]; + + // 即時強度 + const handlePasswordChange = (e) => { + const pwd = e.target.value; + setPasswordScore(getPasswordScore(pwd)); + }; + + // zxcvbn: 0~4 => 0..100 + const progressPercent = passwordScore * 25; + const strengthText = text.strengthTexts[passwordScore] || ""; + const strokeColor = [ + "#ff4d4f", // 0 = 非常弱 + "#ff7a45", // 1 = 弱 + "#faad14", // 2 = 中等 + "#52c41a", // 3 = 強 + "#1677ff", // 4 = 非常強 + ][passwordScore] || "#ff4d4f"; + return ( -
+ - + + {/* 即時強度顯示 */} +
+
+ {text.passwordStrengthTitle} + {strengthText} +
+ +
+ + {/* 顯示建議:使用長密碼短語 */} + + + {/* 成功訊息 */} + {successMessage && ( + + )} + + {/* 錯誤訊息 */} + {submitError && ( + + )} + diff --git a/src/context/AuthContext.js b/src/context/AuthContext.js index f741b10fc32..dc69203e83b 100644 --- a/src/context/AuthContext.js +++ b/src/context/AuthContext.js @@ -1,61 +1,103 @@ -import React, { createContext, useContext, useEffect, useState } from "react"; -import { getUserInfo } from "../utils/mockApi"; +// /src/context/AuthContext.js +import React, { createContext, useContext, useEffect, useMemo, useState } from "react"; -const AuthContext = createContext(null); + +const AuthContext = createContext({ + token: null, + user: null, + loading: true, + loginSuccess: () => {}, + logout: () => {}, + setUser: () => {}, +}); export function AuthProvider({ children }) { const [token, setToken] = useState(null); const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); + // 1. 啟動時檢查 localStorage,有 token 就去後端拿使用者資料 useEffect(() => { const savedToken = localStorage.getItem("token"); - if (savedToken) { - setToken(savedToken); - getUserInfo(savedToken) - .then((data) => { - setUser(data); - setLoading(false); - }) - .catch(() => { - setToken(null); - localStorage.removeItem("token"); - setLoading(false); - }); - } else { + if (!savedToken) { setLoading(false); + return; } - }, []); - const loginSuccess = (loginToken) => { - setToken(loginToken); - localStorage.setItem("token", loginToken); - getUserInfo(loginToken) - .then((data) => setUser(data)) + setToken(savedToken); + + // 呼叫 /auth/me + fetch("https://api.docsaid.org/auth/me", { + method: "GET", + headers: { + Authorization: `Bearer ${savedToken}`, + "Content-Type": "application/json", + }, + }) + .then(async (res) => { + if (!res.ok) { + throw new Error("Token invalid or expired"); + } + return res.json(); + }) + .then((data) => { + // 後端若成功回傳使用者資訊 => 設置 user + setUser(data); + }) .catch(() => { + // token 無效 => 移除 setToken(null); localStorage.removeItem("token"); + }) + .finally(() => setLoading(false)); + }, []); + + // 2. 登入成功後 => 存 token, localStorage, 也可再次呼叫 /auth/me + const loginSuccess = async (loginToken) => { + try { + // 確認 token 可用 => 再呼叫 /auth/me 拿資料 + const res = await fetch("https://api.docsaid.org/auth/me", { + method: "GET", + headers: { + Authorization: `Bearer ${loginToken}`, + "Content-Type": "application/json", + }, }); + if (!res.ok) throw new Error("Invalid token"); + const userData = await res.json(); + + setToken(loginToken); + setUser(userData); + localStorage.setItem("token", loginToken); + } catch (error) { + console.error("登入失敗,無效的 token", error); + // 若需要,也可在這裡彈錯誤訊息 + } }; + // 3. 登出 => 清空 token, user const logout = () => { setToken(null); setUser(null); localStorage.removeItem("token"); }; - const value = { + const value = useMemo(() => ({ token, user, loading, loginSuccess, logout, setUser, - }; + }), [token, user, loading]); return {children}; } export function useAuth() { - return useContext(AuthContext); -} \ No newline at end of file + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth 必須在 AuthProvider 內使用"); + } + return context; +} diff --git a/src/hooks/useAuthHandler.js b/src/hooks/useAuthHandler.js index f36f0d4d0e6..6c376f1c111 100644 --- a/src/hooks/useAuthHandler.js +++ b/src/hooks/useAuthHandler.js @@ -1,46 +1,125 @@ -import { message } from "antd"; +// /src/hooks/useAuthHandler.js +import { message } from "antd"; // 若需要彈出提示 import { useState } from "react"; import { useAuth } from "../context/AuthContext"; -import { loginApi, registerApi, socialLoginApi } from "../utils/mockApi"; export default function useAuthHandler() { const { loginSuccess } = useAuth(); const [loading, setLoading] = useState(false); /** - * 可透過參數自訂成功/失敗提示: - * handleAuth(apiFn, args..., { successMsg, errorMsg }) - * 若沒有傳入,預設就用「操作成功」/「操作失敗」。 + * 登入 + * @param {string} username + * @param {string} password + * @returns {boolean} 是否成功 */ - const handleAuth = async (apiCall, ...args) => { + const login = async (username, password) => { setLoading(true); try { - const result = await apiCall(...args); - loginSuccess(result.token); - message.success("操作成功!"); - return true; - } catch (err) { - message.error(err.message || "操作失敗"); + const res = await fetch("https://api.docsaid.org/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) { + const errData = await res.json().catch(() => ({})); + message.error(errData.detail || "登入失敗"); + return false; + } + + // 成功 => 後端回傳 { access_token, token_type, ... } + const data = await res.json(); + if (data.access_token) { + // 呼叫 AuthContext => 寫入 localStorage => 抓 user 資訊 + await loginSuccess(data.access_token); + message.success("登入成功"); + return true; + } + + message.error("登入失敗: token 不存在"); + return false; + } catch (error) { + console.error("login error:", error); + message.error(error.message || "登入請求失敗"); return false; } finally { setLoading(false); } }; - const login = (username, password) => - handleAuth(loginApi, username, password); + /** + * 註冊 + * @param {{username: string, password: string, force?: boolean}} payload + * @returns {{success: boolean, pwned?: boolean, error?: string, token?: string}} + */ + const register = async ({ username, password, force = false }) => { + setLoading(true); + try { + // 補上假 email (後端必填) + const fakeEmail = `${username}@example.com`; + + const res = await fetch("https://api.docsaid.org/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username, + password, + email: fakeEmail, + phone: null, + birth: null, + avatar: null, + force: force + }), + }); - const register = (username, password) => - handleAuth(registerApi, username, password); + if (!res.ok) { + let errData = {}; + try { + errData = await res.json(); + } catch (parseError) { + console.error("parse error:", parseError); + } - const socialLogin = (provider) => - handleAuth(socialLoginApi, provider); + // 若為 422 => { detail: [ {loc, msg, type} ] } + if (Array.isArray(errData.detail)) { + const msgs = errData.detail.map((d) => d.msg).join("; "); + return { success: false, error: msgs || "Registration failed" }; + } + // 其他錯誤 => 取 detail / error + return { + success: false, + error: errData.detail || errData.error || "Registration failed", + }; + } + + // 成功 => 解析回傳 JSON => e.g. { id, username, pwned, token? } + const data = await res.json(); + + // 若後端同時回傳 token => 可以立即登入 + if (data.token) { + // 寫入 AuthContext => localStorage + await loginSuccess(data.token); + } + + return { + success: !data.pwned, + pwned: data.pwned || false, + token: data.token, + }; + } catch (error) { + console.error("register error:", error); + return { + success: false, + error: error.message || "Network error", + }; + } finally { + setLoading(false); + } + }; return { login, register, - socialLogin, loading, - setLoading, }; } diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index cb15b4e4d45..574261c3e89 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -27,10 +27,10 @@ import React, { useEffect, useState } from "react"; import { useAuth } from "../context/AuthContext"; // Dashboard 子頁面 -import DashboardApiKey from "../components/dashboard/DashboardApiKey"; -import DashboardApiUsage from "../components/dashboard/DashboardApiUsage"; -import DashboardMyComments from "../components/dashboard/DashboardMyComments"; -import DashboardMyInfo from "../components/dashboard/DashboardMyInfo"; +import DashboardApiKey from "../components/Dashboard/ApiKey"; +import DashboardApiUsage from "../components/Dashboard/ApiUsage"; +import DashboardMyComments from "../components/Dashboard/MyComments"; +import DashboardMyInfo from "../components/Dashboard/MyInfo"; const { Header: AntHeader, Sider, Content, Footer } = AntLayout; const { useToken } = antdTheme; // antd v5 提供的 useToken Hook,可讀取設計 tokens @@ -222,9 +222,9 @@ export default function DashboardPage() { style={{ backgroundColor: "#87d068", marginRight: 8 }} // src={user?.avatarUrl} // 可放用戶頭像 > - {user?.name?.[0] || "U"} + {user?.username?.[0]?.toUpperCase() || "U"} - Hi, {user?.name || "User"}! + Hi, {user?.username || "User"}! diff --git a/src/theme/Navbar/Content/index.js b/src/theme/Navbar/Content/index.js index 004d20bed72..10328233ff0 100644 --- a/src/theme/Navbar/Content/index.js +++ b/src/theme/Navbar/Content/index.js @@ -1,7 +1,7 @@ // src/theme/Navbar/Content/index.js import { UserOutlined } from '@ant-design/icons'; import Link from "@docusaurus/Link"; -import { Avatar, Button, Dropdown, Menu, message } from 'antd'; +import { Avatar, Button, Dropdown, message } from 'antd'; import React, { useState } from 'react'; import { useAuth } from '../../../context/AuthContext'; @@ -67,33 +67,29 @@ export default function NavbarContent() { const { token, logout } = useAuth(); const [authVisible, setAuthVisible] = useState(false); - const userMenu = ( - - 儀表板 - - ) - }, - { - key: 'logout', - label: ( - { - logout(); - message.success('已登出'); - }} - > - 登出 - - ), - }, - ]} - /> - ); + const userMenuItems = [ + { + key: 'dashboard', + label: ( + + 儀表板 + + ), + }, + { + key: 'logout', + label: ( + { + logout(); + message.success('已登出'); + }} + > + 登出 + + ), + }, + ]; return ( <> @@ -125,7 +121,7 @@ export default function NavbarContent() { {/* 登入 / 登出 / Avatar */} {token ? ( - + } style={{ cursor: 'pointer', backgroundColor: '#87d068' }} diff --git a/src/utils/mockApi.js b/src/utils/mockApi.js index ed487e22b03..5a3ff54cc2f 100644 --- a/src/utils/mockApi.js +++ b/src/utils/mockApi.js @@ -1,4 +1,261 @@ -// mockApi.js +// 檔案: src/components/dashboard/DashboardMyComments.jsx + +import { + Button, + Card, + Col, + DatePicker, + Form, + Input, + message, + Modal, + Row, + Space, + Table, +} from "antd"; +import moment from "moment"; +import React, { useEffect, useState } from "react"; +import { + deleteCommentApi, + getMyCommentsApi, + updateCommentApi, +} from "../../../utils/mockApi"; + +const { RangePicker } = DatePicker; + +export default function DashboardMyComments() { + const [loading, setLoading] = useState(false); + const [comments, setComments] = useState([]); + + // 用來控制「編輯留言」的彈窗 + const [editingComment, setEditingComment] = useState(null); + const [editModalVisible, setEditModalVisible] = useState(false); + + // Table 搜尋功能 + const [searchText, setSearchText] = useState(""); + + // 批次刪除(row selection) + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + + // 建立日期篩選 + const [dateRange, setDateRange] = useState([]); // [moment|null, moment|null] + + useEffect(() => { + fetchComments(); + }, []); + + const fetchComments = async () => { + setLoading(true); + try { + const data = await getMyCommentsApi(); + setComments(data); + } catch (err) { + message.error(err.message || "取得留言失敗"); + } finally { + setLoading(false); + } + }; + + // 「編輯」按鈕 → 打開 Modal + const handleEdit = (record) => { + setEditingComment({ ...record }); + setEditModalVisible(true); + }; + + // 「刪除」按鈕(單筆) + const handleDelete = async (id) => { + try { + await deleteCommentApi(id); + message.success("留言已刪除"); + setComments((prev) => prev.filter((c) => c.id !== id)); + } catch (err) { + message.error(err.message || "刪除失敗"); + } + }; + + // 「儲存」編輯後的結果 + const handleSaveComment = async (values) => { + try { + await updateCommentApi(values.id, values.content); + message.success("留言已更新"); + setComments((prev) => + prev.map((c) => (c.id === values.id ? { ...c, ...values } : c)) + ); + setEditModalVisible(false); + } catch (err) { + message.error(err.message || "更新失敗"); + } + }; + + // 批次刪除 + const handleBatchDelete = async () => { + if (selectedRowKeys.length === 0) return; + try { + for (let commentId of selectedRowKeys) { + await deleteCommentApi(commentId); + } + message.success("批次刪除成功"); + setComments((prev) => prev.filter((c) => !selectedRowKeys.includes(c.id))); + setSelectedRowKeys([]); // 清空選取 + } catch (err) { + message.error("批次刪除失敗:" + err.message); + } + }; + + // Table columns + const columns = [ + { title: "ID", dataIndex: "id", width: 80 }, + { + title: "留言內容", + dataIndex: "content", + sorter: (a, b) => a.content.localeCompare(b.content), + render: (text) => {text}, + }, + { + title: "建立日期", + dataIndex: "createdAt", + width: 180, + sorter: (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + }, + { + title: "操作", + width: 120, + render: (_, record) => ( + <> + + + + ), + }, + ]; + + // 給 Table 的 rowSelection + const rowSelection = { + selectedRowKeys, + onChange: (newSelectedRowKeys) => { + setSelectedRowKeys(newSelectedRowKeys); + }, + }; + + // 前端資料篩選:根據「留言內容」與「建立日期區間」 + const filteredComments = comments.filter((c) => { + // 1. 關鍵字篩選 + const matchSearch = c.content + .toLowerCase() + .includes(searchText.toLowerCase()); + + // 2. 日期區間篩選 + let matchDate = true; + if (dateRange && dateRange.length === 2 && dateRange[0] && dateRange[1]) { + const start = dateRange[0].startOf("day"); + const end = dateRange[1].endOf("day"); + const created = moment(c.createdAt, "YYYY-MM-DD HH:mm"); + matchDate = created.isBetween(start, end, null, "[]"); + } + + return matchSearch && matchDate; + }); + + return ( + +

我的留言

+ + {/* 上方操作列:搜尋、日期篩選、批次刪除 */} + +
+ setSearchText(e.target.value)} + /> + + + + + setDateRange(dates || [])} + /> + + + + + +
+ + {/* 編輯留言 Modal */} + setEditModalVisible(false)} + onSave={handleSaveComment} + /> + + ); +} + +/** 編輯留言 Modal */ +function EditCommentModal({ visible, onCancel, comment, onSave }) { + const [form] = Form.useForm(); + + useEffect(() => { + if (comment) { + form.setFieldsValue(comment); + } else { + form.resetFields(); + } + }, [comment, form]); + + const onFinish = (values) => { + onSave(values); + }; + + return ( + form.submit()} + okText="儲存" + cancelText="取消" + destroyOnClose + > +
+ + + + + +
+ ); +} export async function loginApi(username, password) { return new Promise((resolve, reject) => { @@ -12,7 +269,7 @@ export async function loginApi(username, password) { resolve({ token: "fake-jwt-token" }); } } - }, 1000); + }, 500); }); } @@ -24,19 +281,7 @@ export async function registerApi(username, password) { } else { resolve({ token: "fake-register-token" }); } - }, 1000); - }); -} - -export async function getUserInfo(token) { - return new Promise((resolve, reject) => { - setTimeout(() => { - if (token) { - resolve({ id: 1, name: "Mock User" }); - } else { - reject(new Error("無效的 token")); - } - }, 800); + }, 500); }); } @@ -48,11 +293,10 @@ export async function socialLoginApi(provider) { } else { reject(new Error("不支援此社群登入方式")); } - }, 1200); + }, 500); }); } -// ↓↓↓新增↓↓↓ export async function forgotPasswordApi(email) { return new Promise((resolve, reject) => { setTimeout(() => { @@ -63,34 +307,37 @@ export async function forgotPasswordApi(email) { } else { reject(new Error("Email 格式不正確,或該 Email 不存在於系統")); } - }, 1000); + }, 500); }); } -export async function resetPasswordApi(token, newPassword) { +export async function getUserInfo(token) { return new Promise((resolve, reject) => { setTimeout(() => { if (!token) { - reject(new Error("無效的重設密碼連結或 Token")); - } else if (!newPassword) { - reject(new Error("請輸入新密碼")); - } else if (newPassword.length < 8) { - reject(new Error("新密碼至少 8 碼")); + reject(new Error("無效的 token")); } else { - resolve(true); + // 假裝回傳使用者資訊 + resolve({ + id: 1, + name: "Mock User", + email: "mock@example.com", + isEmailVerified: false, + avatar: null, + }); } - }, 1000); + }, 500); }); } -export async function updateProfileApi(token, { name, email }) { +export async function updateProfileApi(token, profileData) { return new Promise((resolve, reject) => { setTimeout(() => { if (!token) { return reject(new Error("尚未登入,無法更新")); } resolve(true); - }, 1000); + }, 500); }); } @@ -104,7 +351,7 @@ export async function updatePasswordApi(token, oldPassword, newPassword) { return reject(new Error("密碼資料不完整")); } resolve(true); - }, 1000); + }, 500); }); } @@ -114,12 +361,34 @@ export async function uploadAvatarApi(token, file) { if (!token) { return reject(new Error("尚未登入")); } - const newUrl = "https://via.placeholder.com/100?text=New+Avatar"; - resolve(newUrl); - }, 1500); + resolve("https://via.placeholder.com/100?text=New+Avatar"); + }, 500); + }); +} + +export async function resendVerificationEmailApi(token) { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (!token) { + return reject(new Error("尚未登入")); + } + resolve(true); + }, 500); + }); +} + +export async function deleteAccountApi(token) { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (!token) { + return reject(new Error("尚未登入")); + } + resolve(true); + }, 500); }); } +/* ========== 留言相關 ========== */ export async function getMyCommentsApi() { return new Promise((resolve) => { setTimeout(() => { @@ -127,15 +396,15 @@ export async function getMyCommentsApi() { { id: 101, content: "Great post!", createdAt: "2023-01-01 10:20" }, { id: 102, content: "Nice article.", createdAt: "2023-02-02 15:10" }, ]); - }, 800); + }, 500); }); } export async function updateCommentApi(commentId, newContent) { return new Promise((resolve, reject) => { setTimeout(() => { - if (!commentId || !newContent) { - return reject(new Error("更新留言失敗")); + if (!commentId || !newContent.trim()) { + return reject(new Error("更新留言失敗:留言不可空白")); } resolve(true); }, 500); @@ -146,33 +415,136 @@ export async function deleteCommentApi(commentId) { return new Promise((resolve, reject) => { setTimeout(() => { if (!commentId) { - return reject(new Error("無法刪除")); + return reject(new Error("無法刪除:缺少留言 ID")); } resolve(true); }, 500); }); } -// API Key +/* ========== API Key (單把) ========== + * 你先前的程式只操作單把 Key, 這裡保留原本的 getMyApiKeyApi, regenerateApiKeyApi + * 但對應你最新版的程式需要多把 Key, 故以下是「多把Key」的實作。 + * 如果你確定只需要『單把 Key』邏輯,可在前端程式中刪除對多把Key的使用。 + */ export async function getMyApiKeyApi() { return new Promise((resolve) => { setTimeout(() => { - resolve("fake-api-key-123456789"); - }, 600); + resolve("fake-single-api-key-123456789"); + }, 500); }); } - -export async function regenerateApiKeyApi() { +export async function regenerateApiKeyApi(idOrNothing) { return new Promise((resolve) => { setTimeout(() => { const newKey = - "fake-api-key-" + Math.random().toString(36).slice(2, 8); + "fake-key-" + Math.random().toString(36).slice(2, 8); resolve(newKey); - }, 800); + }, 500); }); } -// API Usage +/* ========== API Key (多把) ========== */ +/** 假裝後端維護多把 Key 的資料 */ +let FAKE_API_KEYS = [ + { + id: "k1", + name: "Default Key", + keyString: "fake-multi-key-11111111", + createdAt: "2023-01-10T09:00:00Z", + expireAt: "", // 空字串代表無到期 + status: "active", // active | expired | disabled + }, + { + id: "k2", + name: "Staging Env Key", + keyString: "fake-multi-key-22222222", + createdAt: "2023-02-15T15:30:00Z", + expireAt: "2024-01-01T00:00:00Z", + status: "active", + }, +]; + +/** 取得多把 key */ +export async function getMyApiKeysApi() { + return new Promise((resolve, reject) => { + setTimeout(() => { + // 假設已登入 + resolve([...FAKE_API_KEYS]); + }, 500); + }); +} + +/** 新增一把 key */ +export async function createApiKeyApi(name) { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (!name.trim()) { + return reject(new Error("名稱不可空白")); + } + const newId = `k${Math.floor(Math.random() * 10000)}`; + const newKeyStr = "fake-multi-key-" + Math.random().toString(36).slice(2, 8); + const now = new Date().toISOString(); + const newObj = { + id: newId, + name: name.trim(), + keyString: newKeyStr, + createdAt: now, + expireAt: "", + status: "active", + }; + FAKE_API_KEYS.push(newObj); + resolve(newObj); + }, 500); + }); +} + +/** 刪除某把 key */ +export async function deleteApiKeyApi(keyId) { + return new Promise((resolve, reject) => { + setTimeout(() => { + FAKE_API_KEYS = FAKE_API_KEYS.filter((k) => k.id !== keyId); + resolve(true); + }, 500); + }); +} + +/** 更新 key 名稱 */ +export async function updateApiKeyNameApi(keyId, newName) { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (!newName.trim()) { + return reject(new Error("名稱不可空白")); + } + FAKE_API_KEYS = FAKE_API_KEYS.map((k) => + k.id === keyId ? { ...k, name: newName.trim() } : k + ); + resolve(true); + }, 500); + }); +} + +/** 查詢 API Key 用量 (展示在 Drawer) */ +export async function getApiKeyUsageApi(keyId) { + return new Promise((resolve, reject) => { + setTimeout(() => { + // 假裝回傳該 Key 的用量、白名單 etc. + const findKey = FAKE_API_KEYS.find((k) => k.id === keyId); + if (!findKey) { + return reject(new Error("找不到此 API Key")); + } + // 自訂 usage, limit, ipWhitelist... + resolve({ + usage: Math.floor(Math.random() * 500), // 已使用 0~499 + limit: 500, + ipWhitelist: ["192.168.0.1", "127.0.0.1"], + }); + }, 500); + }); +} + +/* ========== API Usage ========== */ +/** 後端回傳使用紀錄列表 (僅範例) */ export async function getApiUsageApi() { return new Promise((resolve) => { setTimeout(() => { @@ -192,32 +564,23 @@ export async function getApiUsageApi() { latency: 45, }, ]); - }, 600); - }); -} - -/** 重新寄送 Email 驗證信 */ -export async function resendVerificationEmailApi(token) { - return new Promise((resolve, reject) => { - setTimeout(() => { - if (!token) { - return reject(new Error("尚未登入")); - } - // 假裝寄出成功 - resolve(true); - }, 1200); + }, 500); }); } -/** 刪除帳號 */ -export async function deleteAccountApi(token) { +/* ========== 其他(若有) ========== */ +/** e.g. createCommentApi (只留範例) */ +export async function createCommentApi(content) { return new Promise((resolve, reject) => { setTimeout(() => { - if (!token) { - return reject(new Error("尚未登入")); + if (!content) { + return reject(new Error("留言內容不可為空")); } - // 假裝刪除成功 - resolve(true); - }, 1000); + resolve({ + id: Math.floor(Math.random() * 100000), + content, + createdAt: "2023-08-01 12:00", + }); + }, 500); }); } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 15cc73ad4d7..1ed62f4511a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13189,3 +13189,8 @@ zwitch@^2.0.0: version "2.0.4" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7" integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A== + +zxcvbn@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/zxcvbn/-/zxcvbn-4.4.2.tgz#28ec17cf09743edcab056ddd8b1b06262cc73c30" + integrity sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ== From 04a36f0366fb8bf06b78bae3527a0d8dc9783abb Mon Sep 17 00:00:00 2001 From: zephyr-sh Date: Fri, 7 Mar 2025 22:43:37 +0800 Subject: [PATCH 03/25] [C] Update mail verify --- src/components/Dashboard/MyInfo/index.js | 342 +++++++++++++++-------- src/components/PasswordInput.js | 36 +++ src/components/forms/RegisterForm.js | 86 +----- src/context/AuthContext.js | 198 +++++++++---- src/pages/dashboard.js | 139 +++------ src/pages/email-verified-failed.md | 21 ++ src/pages/email-verified-success.md | 17 ++ src/pages/verify-email.js | 40 +++ src/theme/Navbar/Content/index.js | 33 +-- src/utils/mockApi.js | 30 +- 10 files changed, 556 insertions(+), 386 deletions(-) create mode 100644 src/components/PasswordInput.js create mode 100644 src/pages/email-verified-failed.md create mode 100644 src/pages/email-verified-success.md create mode 100644 src/pages/verify-email.js diff --git a/src/components/Dashboard/MyInfo/index.js b/src/components/Dashboard/MyInfo/index.js index bb61e38479f..ac6e6e334a7 100644 --- a/src/components/Dashboard/MyInfo/index.js +++ b/src/components/Dashboard/MyInfo/index.js @@ -1,5 +1,5 @@ -// src/components/dashboard/DashboardMyInfo.jsx -import { UploadOutlined } from "@ant-design/icons"; +// /src/components/Dashboard/MyInfo/index.js +import { UploadOutlined, UserOutlined } from "@ant-design/icons"; import { Alert, Avatar, @@ -17,50 +17,65 @@ import { Upload, } from "antd"; import moment from "moment"; -import React, { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; +import PasswordInput from "../../../components/PasswordInput"; import { useAuth } from "../../../context/AuthContext"; -import { - deleteAccountApi, - getUserInfo, - resendVerificationEmailApi, - updatePasswordApi, - updateProfileApi, - uploadAvatarApi, -} from "../../../utils/mockApi"; + const { Text } = Typography; export default function DashboardMyInfo() { - const { token, user, setUser } = useAuth(); + const { + token, + user, + setUser, + updateProfile, + sendVerificationEmail, + changePassword, + deleteAccount, + } = useAuth(); + const [infoLoading, setInfoLoading] = useState(false); const [editing, setEditing] = useState(false); const [pwdModalVisible, setPwdModalVisible] = useState(false); const [deleteModalVisible, setDeleteModalVisible] = useState(false); + const [verificationModalVisible, setVerificationModalVisible] = useState(false); const [profileForm] = Form.useForm(); - + const [pwdForm] = Form.useForm(); + const [uploadLoading, setUploadLoading] = useState(false); const [showEmailAlert, setShowEmailAlert] = useState(false); - // 額外顯示使用者的「最後登入時間」與「Email 是否驗證」等資料 - const lastLoginTime = user?.lastLoginTime - ? moment(user.lastLoginTime).format("YYYY-MM-DD HH:mm") + // 禁用未來日期 + const disabledFutureDates = useCallback( + (current) => current && current.isAfter(moment(), "day"), + [] + ); + + // 上次登入資訊 + const lastLoginTime = user?.last_login_at + ? moment(user.last_login_at).format("YYYY-MM-DD HH:mm") + " (UTC+0)" : "無資料"; + const lastLoginIp = user?.last_login_ip || "無資料"; - // 取得最新 user 資料 + // =========== 1. 取得使用者資訊 =========== const refreshUserInfo = async () => { if (!token) return; setInfoLoading(true); try { - const data = await getUserInfo(token); - setUser(data); - - // 依據回傳的 isEmailVerified 判斷是否要顯示警告 - if (data.isEmailVerified === false) { - setShowEmailAlert(true); - } else { - setShowEmailAlert(false); + const res = await fetch("https://api.docsaid.org/auth/me", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + if (!res.ok) { + throw new Error("取得使用者資訊失敗"); } - } catch (error) { - message.error(error.message || "取得資料失敗"); + const data = await res.json(); + setUser(data); + // 若 email 存在但未驗證,顯示警示 + setShowEmailAlert(data.email && data.is_email_verified === false); + } catch (err) { + message.error(err.message || "取得使用者資訊失敗"); } finally { setInfoLoading(false); } @@ -69,95 +84,125 @@ export default function DashboardMyInfo() { useEffect(() => { refreshUserInfo(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [token]); - // 編輯個人資料 + // 若 user 存在但尚未設定 email,自動切換至編輯模式 + useEffect(() => { + if (user && !user.email) { + setEditing(true); + } + }, [user]); + + // =========== 2. 編輯個人資料 =========== const onEditProfile = () => { setEditing(true); if (user) { profileForm.setFieldsValue({ - nickname: user.nickname, + username: user.username, email: user.email, phone: user.phone, - birthday: user.birthday ? moment(user.birthday) : null, + birth: user.birth ? moment(user.birth, "YYYY-MM-DD") : null, }); } }; + // =========== 3. 儲存個人資料 =========== const onSaveProfile = async (values) => { try { - // 將 moment 格式的 birthday 轉為字串或後端需要的格式 - const birthdayString = values.birthday - ? moment(values.birthday).format("YYYY-MM-DD") + const birthString = values.birth + ? moment(values.birth).format("YYYY-MM-DD") : null; - - await updateProfileApi(token, { - ...values, - birthday: birthdayString, - }); - message.success("更新成功"); - setUser((prev) => ({ - ...prev, - nickname: values.nickname, - email: values.email, - phone: values.phone, - birthday: birthdayString, - })); + const payload = { ...values, birth: birthString }; + const updatedUser = await updateProfile(payload); + message.success("個人資料更新成功"); + setUser(updatedUser); + setShowEmailAlert( + updatedUser.email && updatedUser.is_email_verified === false + ); setEditing(false); } catch (err) { - message.error(err.message || "更新失敗"); + if (err.message.includes("Email already exists")) { + profileForm.setFields([{ name: "email", errors: [err.message] }]); + } + message.error(err.message || "個人資料更新失敗"); } }; - // 上傳頭像 + // =========== 4. 上傳頭像 =========== const onUploadAvatar = async ({ file }) => { + if (!token) return; + setUploadLoading(true); try { - const newUrl = await uploadAvatarApi(token, file); + const formData = new FormData(); + formData.append("file", file); + + const res = await fetch("https://api.docsaid.org/auth/avatar", { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + }, + body: formData, + }); + if (!res.ok) { + const errData = await res.json().catch(() => ({})); + throw new Error(errData.detail || "頭像上傳失敗"); + } + const result = await res.json(); message.success("頭像已更新"); setUser((prev) => ({ ...prev, - avatar: newUrl, + avatar: result.avatar, })); } catch (err) { message.error(err.message || "頭像上傳失敗"); + } finally { + setUploadLoading(false); } }; - // 變更密碼 + // =========== 5. 變更密碼 =========== const openChangePwdModal = () => { setPwdModalVisible(true); }; + const onChangePassword = async (values) => { if (values.newPassword !== values.confirmPassword) { - return message.error("兩次輸入密碼不一致"); + return message.error("兩次輸入的密碼不一致"); } try { - await updatePasswordApi(token, values.oldPassword, values.newPassword); - message.success("密碼已變更!"); + await changePassword(values.oldPassword, values.newPassword); + message.success("密碼變更成功!"); setPwdModalVisible(false); + pwdForm.resetFields(); } catch (err) { - message.error(err.message || "變更密碼失敗"); + console.error("變更密碼錯誤:", err); + message.error(err.message || "密碼變更失敗"); } }; - // 重新寄送驗證信 - const onResendVerification = async () => { + // =========== 6. 重新寄送驗證信 =========== + const onResendVerification = () => { + if (!user?.email) return; + setVerificationModalVisible(true); + }; + + const handleVerificationOk = async () => { try { - await resendVerificationEmailApi(token); - message.success("驗證信已重新寄送,請檢查您的信箱"); + await sendVerificationEmail(user.email); + message.success("驗證信已寄出,請檢查信箱"); + setVerificationModalVisible(false); } catch (err) { message.error(err.message || "寄送驗證信失敗"); } }; - // 刪除帳號 + // =========== 7. 刪除帳號 =========== const onDeleteAccount = async () => { try { - await deleteAccountApi(token); + await deleteAccount(); message.success("帳號已刪除,將導回主頁"); - // 這裡也可以登出並導回主頁 - // window.location.href = "/"; setDeleteModalVisible(false); + window.location.href = "/"; } catch (err) { message.error(err.message || "刪除帳號失敗"); } @@ -167,23 +212,51 @@ export default function DashboardMyInfo() { return ; } + const renderEmailStatus = () => { + if (!user?.email) { + return 尚未設定; + } + if (user.is_email_verified) { + return 已驗證; + } + return ( + + 未驗證 + + + ); + }; + return (

我的資訊

- {showEmailAlert && ( + {showEmailAlert && user?.email && !user.is_email_verified && ( + )} + + {!user?.email && !editing && ( + - 請至信箱收信並點擊驗證連結, - -
+ 補上 Email。 + } - type="warning" + type="info" showIcon /> )} @@ -192,10 +265,10 @@ export default function DashboardMyInfo() {
} + onError={() => false} />
@@ -217,46 +290,47 @@ export default function DashboardMyInfo() { layout="vertical" onFinish={onSaveProfile} initialValues={{ - nickname: user?.nickname, + username: user?.username, email: user?.email, phone: user?.phone, - birthday: user?.birthday - ? moment(user.birthday, "YYYY-MM-DD") - : null, + birth: user?.birth ? moment(user.birth, "YYYY-MM-DD") : null, }} > - + - + {user?.is_email_verified === false ? : } + + - ({ + value: value ? moment(value, "YYYY-MM-DD").startOf("day") : null, + })} + getValueFromEvent={(date) => + date ? date.format("YYYY-MM-DD") : null + } > - - - - current && current > moment()} - placeholder="選擇生日" + disabledDate={disabledFutureDates} /> - @@ -303,35 +386,59 @@ export default function DashboardMyInfo() { - {/* 密碼變更 Modal */} setPwdModalVisible(false)} + onCancel={() => { + setPwdModalVisible(false); + pwdForm.resetFields(); + }} onSubmit={onChangePassword} + form={pwdForm} /> - {/* 刪除帳號 Modal */} setDeleteModalVisible(false)} onDelete={onDeleteAccount} /> + + {/* 狀態控制的驗證信 Modal */} + document.body} + onOk={handleVerificationOk} + onCancel={() => setVerificationModalVisible(false)} + okText="確定" + cancelText="取消" + okButtonProps={{ disabled: /@example\.com$/i.test(user?.email) }} + > + {/@example\.com$/i.test(user?.email) ? ( +

+ 預設信箱 (example.com) 無法用於驗證,請更換為有效的 Email。 +

+ ) : ( + <> +

+ 系統將發送驗證信至: + {user?.email} +

+

請確認此 Email 是否正確?

+ + )} +
); } -/** 變更密碼 Modal */ -function ChangePasswordModal({ visible, onCancel, onSubmit }) { - const [form] = Form.useForm(); +function ChangePasswordModal({ visible, onCancel, onSubmit, form }) { const [passwordStrength, setPasswordStrength] = useState(""); const onFinish = (values) => { onSubmit(values); setPasswordStrength(""); - form.resetFields(); }; - // 簡單示範一個密碼強度偵測 const handlePasswordChange = (e) => { const pwd = e.target.value; if (pwd.length < 6) { @@ -345,8 +452,8 @@ function ChangePasswordModal({ visible, onCancel, onSubmit }) { return ( { onCancel(); form.resetFields(); @@ -364,17 +471,16 @@ function ChangePasswordModal({ visible, onCancel, onSubmit }) { >
- + <> 新密碼 {passwordStrength && ( (強度:{passwordStrength}) )} - + } name="newPassword" rules={[ @@ -382,9 +488,8 @@ function ChangePasswordModal({ visible, onCancel, onSubmit }) { { min: 8, message: "至少 8 碼" }, ]} > - + - { + const value = e.target.value; + const result = zxcvbn(value); + setScore(result.score); + if (onChange) onChange(e); + }; + + return ( +
+ +
+ {`密碼強度:${strengthTexts[score]}`} + +
+
+ ); +} diff --git a/src/components/forms/RegisterForm.js b/src/components/forms/RegisterForm.js index ec23b3f1f60..afcfb5e9b12 100644 --- a/src/components/forms/RegisterForm.js +++ b/src/components/forms/RegisterForm.js @@ -1,17 +1,10 @@ // /components/forms/RegisterForm.js import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; -import { Alert, Button, Form, Input, Progress } from "antd"; +import { Alert, Button, Form, Input } from "antd"; import React, { useState } from "react"; -import zxcvbn from "zxcvbn"; import { useAuth } from "../../context/AuthContext"; +import PasswordInput from "../PasswordInput"; -function getPasswordScore(password) { - if (!password) return 0; - const result = zxcvbn(password); - return result.score; // 0~4 -} - -// 多國語系 const localeText = { "zh-hant": { usernameLabel: "帳號", @@ -54,12 +47,7 @@ const localeText = { }, }; -export default function RegisterForm({ - onLogin, - onSuccess, - onRegister, - loading -}) { +export default function RegisterForm({ onLogin, onSuccess, onRegister, loading }) { const { i18n: { currentLocale }, } = useDocusaurusContext(); @@ -75,12 +63,9 @@ export default function RegisterForm({ const [submitError, setSubmitError] = useState(""); const [successMessage, setSuccessMessage] = useState(""); - // 用來顯示即時強度 - const [passwordScore, setPasswordScore] = useState(0); - /** * 表單送出 - * 不允許弱密碼 => 只要後端回 pwned=true => fail => 顯示錯誤 + * 不允許弱密碼 => 後端若回 pwned=true,則註冊失敗並顯示錯誤 */ const onFinish = async (values) => { setSubmitError(""); @@ -103,31 +88,26 @@ export default function RegisterForm({ } window.location.href = "/dashboard"; } else { - // 若 pwned=true => 後端要求不允許 => 顯示錯誤 if (result.pwned) { setSubmitError(text.pwnedWarning); } else { - // 其他錯誤 (ex: 帳號重複) setSubmitError(result.error || "Registration failed"); } } }; /** - * 註冊成功 => 重置表單 + (若需要) loginSuccess(token) + 跳轉 + * 註冊成功後重置表單並自動登入(若後端回 token) */ const finishRegisterSuccess = (result) => { setSuccessMessage(text.successMsg); form.resetFields(); - setPasswordScore(0); - - // 若後端回 token => 自動登入 if (result.token) { loginSuccess(result.token); } }; - // 基本規則: 必填 & >=8 chars + // 密碼欄位的基本規則 const passwordRules = [ { required: true, @@ -146,23 +126,6 @@ export default function RegisterForm({ }, ]; - // 即時強度 - const handlePasswordChange = (e) => { - const pwd = e.target.value; - setPasswordScore(getPasswordScore(pwd)); - }; - - // zxcvbn: 0~4 => 0..100 - const progressPercent = passwordScore * 25; - const strengthText = text.strengthTexts[passwordScore] || ""; - const strokeColor = [ - "#ff4d4f", // 0 = 非常弱 - "#ff7a45", // 1 = 弱 - "#faad14", // 2 = 中等 - "#52c41a", // 3 = 強 - "#1677ff", // 4 = 非常強 - ][passwordScore] || "#ff4d4f"; - return (
- +
- {/* 即時強度顯示 */} -
-
- {text.passwordStrengthTitle} - {strengthText} -
- -
- - {/* 顯示建議:使用長密碼短語 */} - - - {/* 成功訊息 */} + + {successMessage && ( - + )} - {/* 錯誤訊息 */} {submitError && ( - + )} diff --git a/src/context/AuthContext.js b/src/context/AuthContext.js index dc69203e83b..8365706de51 100644 --- a/src/context/AuthContext.js +++ b/src/context/AuthContext.js @@ -1,7 +1,6 @@ // /src/context/AuthContext.js import React, { createContext, useContext, useEffect, useMemo, useState } from "react"; - const AuthContext = createContext({ token: null, user: null, @@ -9,87 +8,178 @@ const AuthContext = createContext({ loginSuccess: () => {}, logout: () => {}, setUser: () => {}, + updateProfile: () => {}, + verifyEmail: () => {}, + sendVerificationEmail: () => {}, + changePassword: () => {}, + deleteAccount: () => {}, }); +const API_BASE = "https://api.docsaid.org"; + +/** + * 通用 API 請求工具 + * @param {string} endpoint API 路徑 (例如 /auth/me) + * @param {string} method HTTP 方法 + * @param {string|null} token 若有 token 則自動加入 Authorization header + * @param {object|FormData|null} body 若為物件則轉成 JSON;若為 FormData 則直接傳送 + */ +async function apiRequest(endpoint, method = "GET", token = null, body = null) { + const headers = {}; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + const isFormData = body instanceof FormData; + if (body && !isFormData) { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(body); + } + const res = await fetch(`${API_BASE}${endpoint}`, { + method, + headers, + body: body || undefined, + }); + + // 判斷回傳格式 + const contentType = res.headers.get("Content-Type"); + let data = {}; + if (contentType && contentType.includes("application/json")) { + data = await res.json(); + } else { + data = await res.text(); + } + if (!res.ok) { + // 若在開發環境中,詳細錯誤資訊會輸出到 console;生產環境則只拋出簡單訊息 + if (process.env.NODE_ENV !== "production") { + console.error(`API Request Failed [${method} ${endpoint}]:`, data); + } + throw new Error(data.detail || data || "請求失敗"); + } + return data; +} + export function AuthProvider({ children }) { const [token, setToken] = useState(null); const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); - // 1. 啟動時檢查 localStorage,有 token 就去後端拿使用者資料 + // 啟動時檢查 localStorage 並取得使用者資訊 useEffect(() => { - const savedToken = localStorage.getItem("token"); - if (!savedToken) { - setLoading(false); - return; - } - - setToken(savedToken); - - // 呼叫 /auth/me - fetch("https://api.docsaid.org/auth/me", { - method: "GET", - headers: { - Authorization: `Bearer ${savedToken}`, - "Content-Type": "application/json", - }, - }) - .then(async (res) => { - if (!res.ok) { - throw new Error("Token invalid or expired"); - } - return res.json(); - }) - .then((data) => { - // 後端若成功回傳使用者資訊 => 設置 user + const initAuth = async () => { + const savedToken = localStorage.getItem("token"); + if (!savedToken) { + setLoading(false); + return; + } + setToken(savedToken); + try { + const data = await apiRequest("/auth/me", "GET", savedToken); setUser(data); - }) - .catch(() => { - // token 無效 => 移除 + } catch (error) { + // Token 失效 => 清空 setToken(null); localStorage.removeItem("token"); - }) - .finally(() => setLoading(false)); + } finally { + setLoading(false); + } + }; + initAuth(); }, []); - // 2. 登入成功後 => 存 token, localStorage, 也可再次呼叫 /auth/me + // 登入成功後,儲存 token 並刷新使用者資料 const loginSuccess = async (loginToken) => { try { - // 確認 token 可用 => 再呼叫 /auth/me 拿資料 - const res = await fetch("https://api.docsaid.org/auth/me", { - method: "GET", - headers: { - Authorization: `Bearer ${loginToken}`, - "Content-Type": "application/json", - }, - }); - if (!res.ok) throw new Error("Invalid token"); - const userData = await res.json(); - + const userData = await apiRequest("/auth/me", "GET", loginToken); setToken(loginToken); setUser(userData); localStorage.setItem("token", loginToken); } catch (error) { - console.error("登入失敗,無效的 token", error); - // 若需要,也可在這裡彈錯誤訊息 + if (process.env.NODE_ENV !== "production") { + console.error("登入失敗", error); + } } }; - // 3. 登出 => 清空 token, user + // 登出:清空 token 與使用者資料,並導向首頁 const logout = () => { setToken(null); setUser(null); localStorage.removeItem("token"); + window.location.href = "/"; + }; + + // 更新個人資料,若 email 重複會拋出錯誤 + const updateProfile = async (payload) => { + if (!token) throw new Error("尚未登入"); + try { + const newUser = await apiRequest("/auth/profile", "PUT", token, payload); + setUser(newUser); + return newUser; + } catch (error) { + if (process.env.NODE_ENV !== "production") { + console.error("更新個人資料錯誤:", error); + } + throw new Error(error.message || "更新失敗"); + } + }; + + // 寄送驗證信 + const sendVerificationEmail = async (email) => { + if (!token) throw new Error("尚未登入"); + return apiRequest("/auth/send-verification-email", "POST", token, { email }); + }; + + // 驗證 Email,成功後刷新使用者資訊 + const verifyEmail = async (payload) => { + const data = await apiRequest("/auth/verify-email", "POST", null, payload); + if (token) { + try { + const updatedUser = await apiRequest("/auth/me", "GET", token); + setUser(updatedUser); + } catch (e) { + if (process.env.NODE_ENV !== "production") { + console.error("刷新使用者資訊失敗", e); + } + } + } + return data; + }; + + // 變更密碼 + const changePassword = async (oldPassword, newPassword) => { + if (!token) throw new Error("尚未登入"); + return apiRequest( + "/auth/change-password", + "POST", + token, + { old_password: oldPassword, new_password: newPassword } + ); + }; + + // 刪除帳號,成功後自動登出 + const deleteAccount = async () => { + if (!token) throw new Error("尚未登入"); + await apiRequest("/auth/delete", "DELETE", token); + logout(); + return { message: "帳號已刪除" }; }; - const value = useMemo(() => ({ - token, - user, - loading, - loginSuccess, - logout, - setUser, - }), [token, user, loading]); + const value = useMemo( + () => ({ + token, + user, + loading, + loginSuccess, + logout, + setUser, + updateProfile, + verifyEmail, + sendVerificationEmail, + changePassword, + deleteAccount, + }), + [token, user, loading] + ); return {children}; } diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index 574261c3e89..328a4c0b2d3 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -1,3 +1,4 @@ +// src/pages/dashboard.js import { CommentOutlined, DatabaseOutlined, @@ -11,7 +12,6 @@ import { import Layout from "@theme/Layout"; // Docusaurus Layout import { Layout as AntLayout, - Avatar, Breadcrumb, Button, Col, @@ -22,8 +22,7 @@ import { theme as antdTheme, message, } from "antd"; -import React, { useEffect, useState } from "react"; - +import React, { useEffect, useMemo, useState } from "react"; import { useAuth } from "../context/AuthContext"; // Dashboard 子頁面 @@ -33,29 +32,22 @@ import DashboardMyComments from "../components/Dashboard/MyComments"; import DashboardMyInfo from "../components/Dashboard/MyInfo"; const { Header: AntHeader, Sider, Content, Footer } = AntLayout; -const { useToken } = antdTheme; // antd v5 提供的 useToken Hook,可讀取設計 tokens export default function DashboardPage() { const { token, user, loading, logout } = useAuth(); - const [fetching, setFetching] = useState(false); - - // 預設選單指向 "myinfo" const [selectedKey, setSelectedKey] = useState("myinfo"); - - // 側邊欄折疊狀態 const [collapsed, setCollapsed] = useState(false); - const { token: designToken } = useToken(); - // 這裡可讀取 antd 的設計 Token,例如 designToken.colorBgContainer + const { token: designToken } = antdTheme.useToken(); useEffect(() => { if (!loading && !token) { message.warning("請先登入"); - // 例如:window.location.href = "/"; + // 可根據需求導向登入頁面,例如:window.location.href = "/"; } }, [loading, token]); - const renderContent = () => { + const contentComponent = useMemo(() => { switch (selectedKey) { case "myinfo": return ; @@ -68,10 +60,9 @@ export default function DashboardPage() { default: return null; } - }; + }, [selectedKey]); - // 顯示頂端「麵包屑 / 頁面標題」示例(若需要更動態可再擴充) - const pageTitle = (() => { + const pageTitle = useMemo(() => { switch (selectedKey) { case "myinfo": return "我的資訊"; @@ -84,39 +75,36 @@ export default function DashboardPage() { default: return "未定義"; } - })(); - - // 建立右上角用戶選單 - const userMenu = ( - , - label: "回主站", - onClick: () => { - window.location.href = "/"; - }, - }, - { - key: "logout", - icon: , - label: "登出", - onClick: () => { - logout(); - }, - }, - ]} - /> - ); - - if (loading || fetching) { + }, [selectedKey]); + + // 建立右上角用戶選單,使用新版 Dropdown API + const userMenuItems = [ + { + key: "backHome", + icon: , + label: "回主站", + onClick: () => { + window.location.href = "/"; + }, + }, + { + key: "logout", + icon: , + label: "登出", + onClick: logout, + }, + ]; + + // 使用新版 Breadcrumb API + const breadcrumbItems = [ + { title: "我的後台" }, + { title: pageTitle }, + ]; + + if (loading) { return ( - + ); } @@ -134,17 +122,13 @@ export default function DashboardPage() { return ( - {/* 可伸縮的側邊欄 */} setCollapsed(value)} - style={{ - borderRight: "1px solid #ddd", - }} + onCollapse={setCollapsed} + style={{ borderRight: "1px solid #ddd" }} > - {/* Logo 或標題區塊 */}
{collapsed ? "後台" : "我的後台"}
- setSelectedKey(e.key)} items={[ - { - key: "myinfo", - icon: , - label: "我的資訊", - }, - { - key: "comments", - icon: , - label: "我的留言", - }, - { - key: "apikey", - icon: , - label: "我的 API Key", - }, - { - key: "apiusage", - icon: , - label: "API 使用紀錄", - }, + { key: "myinfo", icon: , label: "我的資訊" }, + { key: "comments", icon: , label: "我的留言" }, + { key: "apikey", icon: , label: "我的 API Key" }, + { key: "apiusage", icon: , label: "API 使用紀錄" }, ]} /> - - {/* 頂欄 */}
- {/* 折疊按鈕 */} - - 我的後台 - {pageTitle} - + - - +
- - {user?.username?.[0]?.toUpperCase() || "U"} - Hi, {user?.username || "User"}!
- - {/* 主要內容區塊 */}
- {renderContent()} + {contentComponent}
- - {/* Footer */}
© {new Date().getFullYear()} My Company. All rights reserved. diff --git a/src/pages/email-verified-failed.md b/src/pages/email-verified-failed.md new file mode 100644 index 00000000000..d6349c8b877 --- /dev/null +++ b/src/pages/email-verified-failed.md @@ -0,0 +1,21 @@ +--- +id: email-verified-fail +--- + +# Email 驗證失敗 ⚠️ + +很抱歉,您的信箱驗證連結無效或已過期,原因可能是: + +1. 連結已過期,請再次透過帳號設定頁面重新發送驗證信。 +2. 使用者帳號已驗證過,無需再次驗證。 +3. 使用者不存在,請確認您的帳號是否正確。 + +請再次透過帳號設定頁面重新發送驗證信。 + +若持續遇到問題,請立即聯繫我們的客服團隊,我們將竭誠為您服務: + +- **客服信箱**:docsaidlab@gmail.com + +謝謝您的耐心與合作! + +DocSaid 團隊敬上 diff --git a/src/pages/email-verified-success.md b/src/pages/email-verified-success.md new file mode 100644 index 00000000000..90967cf70ba --- /dev/null +++ b/src/pages/email-verified-success.md @@ -0,0 +1,17 @@ +--- +id: email-verified-success +--- + +# Email 驗證成功 🎉 + +恭喜您,您的 Email 已成功驗證! + +您現在可以盡情享受 DocSaid 提供的所有功能。 + +若有任何問題,歡迎隨時與我們的客服團隊聯絡: + +- **客服信箱**:docsaidlab@gmail.com + +感謝您的支持與使用! + +DocSaid 團隊敬上 diff --git a/src/pages/verify-email.js b/src/pages/verify-email.js new file mode 100644 index 00000000000..4dce48661d8 --- /dev/null +++ b/src/pages/verify-email.js @@ -0,0 +1,40 @@ +// src/pages/verify-email.js +import { useLocation } from "@docusaurus/router"; +import { message } from "antd"; +import React, { useEffect, useState } from "react"; + +// 假設你的 AuthContext 放在 src/context/AuthContext.js +import { useAuth } from "../context/AuthContext"; + +export default function VerifyEmailPage() { + const { verifyEmail } = useAuth(); // 你在 AuthContext 裡寫好的函式 + const location = useLocation(); // 取得目前路由資訊 (含 search) + const [status, setStatus] = useState("驗證中..."); + + useEffect(() => { + // 解析網址後面的 "?token=xxxx" + const searchParams = new URLSearchParams(location.search); + const token = searchParams.get("token"); + if (!token) { + setStatus("無效的連結,找不到 token"); + return; + } + + // 呼叫後端進行驗證 + verifyEmail({ token }) + .then(() => { + setStatus("Email 驗證成功!"); + message.success("Email 驗證成功,您可以使用完整功能囉!"); + }) + .catch((err) => { + setStatus(err.message || "驗證失敗"); + message.error(err.message || "驗證失敗"); + }); + }, [location, verifyEmail]); + + return ( +
+

{status}

+
+ ); +} diff --git a/src/theme/Navbar/Content/index.js b/src/theme/Navbar/Content/index.js index 10328233ff0..42b6a811f33 100644 --- a/src/theme/Navbar/Content/index.js +++ b/src/theme/Navbar/Content/index.js @@ -1,4 +1,3 @@ -// src/theme/Navbar/Content/index.js import { UserOutlined } from '@ant-design/icons'; import Link from "@docusaurus/Link"; import { Avatar, Button, Dropdown, message } from 'antd'; @@ -19,7 +18,7 @@ import NavbarItem from '@theme/NavbarItem'; import SearchBar from '@theme/SearchBar'; import styles from './styles.module.css'; -import AuthModal from '../../../components/AuthModal'; // 自訂的 Modal +import AuthModal from '../../../components/AuthModal'; function useNavbarItems() { return useThemeConfig().navbar.items; @@ -29,16 +28,7 @@ function NavbarItems({items}) { return ( <> {items.map((item, i) => ( - - new Error( - `A theme navbar item failed to render. -Please double-check the following navbar item (themeConfig.navbar.items) of your Docusaurus config: -${JSON.stringify(item, null, 2)}`, - {cause: error}, - ) - }> + ))} @@ -58,13 +48,10 @@ function NavbarContentLayout({left, right}) { export default function NavbarContent() { const mobileSidebar = useNavbarMobileSidebar(); const items = useNavbarItems(); - // 官方 internal function,將 config 中的 items 拆分左右 const [leftItems, rightItems] = splitNavbarItems(items); - // 如果 config 中沒有 search item, 預設用 SearchBar const searchBarItem = items.find((item) => item.type === 'search'); - // === Auth 狀態 / Modal === - const { token, logout } = useAuth(); + const { token, user, logout } = useAuth(); const [authVisible, setAuthVisible] = useState(false); const userMenuItems = [ @@ -96,34 +83,25 @@ export default function NavbarContent() { - {/* 手機版側邊欄切換按鈕 */} {!mobileSidebar.disabled && } - {/* Logo */} - {/* 左側 items */} } right={ <> - {/* 右側 items */} - - {/* 顯示 Theme 切換按鈕 (若你 config 有 colorMode.enableSwitch) */} - - {/* 若 config 裡沒定義 search,預設就顯示一個 SearchBar */} {!searchBarItem && ( )} - - {/* 登入 / 登出 / Avatar */} {token ? ( } + src={user?.avatar} + icon={!user?.avatar ? : undefined} style={{ cursor: 'pointer', backgroundColor: '#87d068' }} /> @@ -138,7 +116,6 @@ export default function NavbarContent() { } /> - {/* Auth Modal 放最外層 */} setAuthVisible(false)} /> ); diff --git a/src/utils/mockApi.js b/src/utils/mockApi.js index 5a3ff54cc2f..f438d175eda 100644 --- a/src/utils/mockApi.js +++ b/src/utils/mockApi.js @@ -1,24 +1,24 @@ // 檔案: src/components/dashboard/DashboardMyComments.jsx import { - Button, - Card, - Col, - DatePicker, - Form, - Input, - message, - Modal, - Row, - Space, - Table, + Button, + Card, + Col, + DatePicker, + Form, + Input, + message, + Modal, + Row, + Space, + Table, } from "antd"; import moment from "moment"; import React, { useEffect, useState } from "react"; import { - deleteCommentApi, - getMyCommentsApi, - updateCommentApi, + deleteCommentApi, + getMyCommentsApi, + updateCommentApi, } from "../../../utils/mockApi"; const { RangePicker } = DatePicker; @@ -322,7 +322,7 @@ export async function getUserInfo(token) { id: 1, name: "Mock User", email: "mock@example.com", - isEmailVerified: false, + is_email_verified: false, avatar: null, }); } From 47b8289a947d280f5d4f6581799b195297b55d3c Mon Sep 17 00:00:00 2001 From: zephyr-sh Date: Fri, 7 Mar 2025 22:50:13 +0800 Subject: [PATCH 04/25] [F] Fixed DatePicker setting error --- src/components/Dashboard/MyInfo/index.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/Dashboard/MyInfo/index.js b/src/components/Dashboard/MyInfo/index.js index ac6e6e334a7..37cecca47d8 100644 --- a/src/components/Dashboard/MyInfo/index.js +++ b/src/components/Dashboard/MyInfo/index.js @@ -326,9 +326,15 @@ export default function DashboardMyInfo() { date ? date.format("YYYY-MM-DD") : null } > + { + if (open) { + profileForm.setFieldsValue({ birth: null }); + } + }} /> From dbde148a292bafd573c45e641d5396938c75c6fd Mon Sep 17 00:00:00 2001 From: zephyr-sh Date: Fri, 7 Mar 2025 23:27:39 +0800 Subject: [PATCH 05/25] [C] Update change pwd settings --- src/components/Dashboard/MyInfo/index.js | 41 ++++++++++++++++++++---- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/src/components/Dashboard/MyInfo/index.js b/src/components/Dashboard/MyInfo/index.js index 37cecca47d8..234706f4966 100644 --- a/src/components/Dashboard/MyInfo/index.js +++ b/src/components/Dashboard/MyInfo/index.js @@ -21,7 +21,6 @@ import React, { useCallback, useEffect, useState } from "react"; import PasswordInput from "../../../components/PasswordInput"; import { useAuth } from "../../../context/AuthContext"; - const { Text } = Typography; export default function DashboardMyInfo() { @@ -165,9 +164,14 @@ export default function DashboardMyInfo() { setPwdModalVisible(true); }; + const [errorModalVisible, setErrorModalVisible] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + const onChangePassword = async (values) => { if (values.newPassword !== values.confirmPassword) { - return message.error("兩次輸入的密碼不一致"); + setErrorMessage("兩次輸入的密碼不一致"); + setErrorModalVisible(true); + return; } try { await changePassword(values.oldPassword, values.newPassword); @@ -176,7 +180,9 @@ export default function DashboardMyInfo() { pwdForm.resetFields(); } catch (err) { console.error("變更密碼錯誤:", err); - message.error(err.message || "密碼變更失敗"); + const errorMsg = err.response?.data?.detail || err.message || "密碼變更失敗"; + setErrorMessage(errorMsg); + setErrorModalVisible(true); } }; @@ -326,7 +332,6 @@ export default function DashboardMyInfo() { date ? date.format("YYYY-MM-DD") : null } > - )} + + setErrorModalVisible(false)} + footer={[ + + ]} + > +

{errorMessage}

+
+
); } @@ -491,10 +510,20 @@ function ChangePasswordModal({ visible, onCancel, onSubmit, form }) { name="newPassword" rules={[ { required: true, message: "請輸入新密碼" }, - { min: 8, message: "至少 8 碼" }, + { + validator: async (_, value) => { + if (!value) { + return Promise.reject(new Error("請輸入新密碼")); + } + if (value.length < 8) { + return Promise.reject(new Error("至少 8 碼")); + } + return Promise.resolve(); + }, + }, ]} > - + Date: Sat, 8 Mar 2025 14:25:16 +0800 Subject: [PATCH 06/25] [C] Update login page --- src/components/Dashboard/MyInfo/index.js | 365 +++++++++++++++++------ src/components/PasswordInput.js | 33 +- src/components/forms/LoginForm.js | 104 ++++++- src/components/forms/RegisterForm.js | 6 - src/hooks/useAuthHandler.js | 45 ++- src/pages/dashboard.js | 2 +- src/pages/verify-email.js | 40 --- 7 files changed, 426 insertions(+), 169 deletions(-) delete mode 100644 src/pages/verify-email.js diff --git a/src/components/Dashboard/MyInfo/index.js b/src/components/Dashboard/MyInfo/index.js index 234706f4966..f72f2699e91 100644 --- a/src/components/Dashboard/MyInfo/index.js +++ b/src/components/Dashboard/MyInfo/index.js @@ -1,5 +1,6 @@ // /src/components/Dashboard/MyInfo/index.js import { UploadOutlined, UserOutlined } from "@ant-design/icons"; +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { Alert, Avatar, @@ -23,7 +24,127 @@ import { useAuth } from "../../../context/AuthContext"; const { Text } = Typography; +const localeText = { + "zh-hant": { + myInfoTitle: "我的資訊", + emailNotVerifiedAlertTitle: "您的 Email 尚未驗證", + emailNotVerifiedAlertDesc: "沒有驗證信箱,帳號密碼丟失後無法找回。請點選下方寄送驗證信,並檢查您的信箱。", + noEmailAlertTitle: "尚未填寫 Email", + noEmailAlertDesc: "您尚未綁定 Email,請點 {editLink} 補上 Email。", + uploadAvatarButtonUploading: "上傳中...", + uploadAvatarButton: "上傳頭像", + accountLabel: "帳號", + emailLabel: "Email", + phoneLabel: "電話", + birthLabel: "生日", + notSet: "(未設定)", + lastLoginTimeLabel: "上次登入時間:", + lastLoginIpLabel: "上次登入 IP:", + statusLabel: "狀態", + editButton: "編輯", + changePasswordButton: "變更密碼", + deleteAccountButton: "刪除帳號", + successMsg: "個人資料更新成功", + fetchUserInfoFailure: "取得使用者資訊失敗", + avatarUploadFailure: "頭像上傳失敗", + avatarUploadSuccess: "頭像已更新", + verified: "已驗證", + notVerified: "未驗證", + resendVerification: "寄送驗證信", + changePasswordSuccess: "密碼變更成功!", + passwordMismatch: "兩次輸入的密碼不一致", + changePasswordFailureTitle: "變更密碼失敗", + verificationModalTitle: "寄送驗證信", + verificationModalOk: "確定", + verificationModalCancel: "取消", + verificationModalExampleEmail: "預設信箱 (example.com) 無法用於驗證,請更換為有效的 Email。", + verificationModalDesc: "系統將發送驗證信至:{email}\n請確認此 Email 是否正確?", + deleteAccountSuccess: "帳號已刪除,將導回主頁", + saveButton: "儲存", + cancelButton: "取消", + }, + en: { + myInfoTitle: "My Information", + emailNotVerifiedAlertTitle: "Your Email is not verified", + emailNotVerifiedAlertDesc: "Please click the button below to resend the verification email and check your inbox.", + noEmailAlertTitle: "Email not set", + noEmailAlertDesc: "You have not bound an Email. Please click {editLink} to add an Email.", + uploadAvatarButtonUploading: "Uploading...", + uploadAvatarButton: "Upload Avatar", + accountLabel: "Account", + emailLabel: "Email", + phoneLabel: "Phone", + birthLabel: "Birth", + notSet: "(Not set)", + lastLoginTimeLabel: "Last Login Time: ", + lastLoginIpLabel: "Last Login IP: ", + statusLabel: "Status", + editButton: "Edit", + changePasswordButton: "Change Password", + deleteAccountButton: "Delete Account", + successMsg: "Profile updated successfully", + fetchUserInfoFailure: "Failed to fetch user information", + avatarUploadFailure: "Avatar upload failed", + avatarUploadSuccess: "Avatar updated", + verified: "Verified", + notVerified: "Not verified", + resendVerification: "Resend Verification", + changePasswordSuccess: "Password changed successfully!", + passwordMismatch: "Passwords do not match", + changePasswordFailureTitle: "Change Password Failed", + verificationModalTitle: "Resend Verification Email", + verificationModalOk: "Confirm", + verificationModalCancel: "Cancel", + verificationModalExampleEmail: "Default email (example.com) cannot be used for verification. Please change to a valid Email.", + verificationModalDesc: "The system will send a verification email to: {email}\nPlease confirm if this Email is correct.", + deleteAccountSuccess: "Account deleted, redirecting to homepage", + saveButton: "Save", + cancelButton: "Cancel", + }, + ja: { + myInfoTitle: "私の情報", + emailNotVerifiedAlertTitle: "メールが未認証です", + emailNotVerifiedAlertDesc: "下のボタンをクリックして認証メールを再送信し、受信箱を確認してください。", + noEmailAlertTitle: "メール未設定", + noEmailAlertDesc: "メールが登録されていません。{editLink} をクリックしてメールを追加してください。", + uploadAvatarButtonUploading: "アップロード中...", + uploadAvatarButton: "アバターをアップロード", + accountLabel: "アカウント", + emailLabel: "メール", + phoneLabel: "電話", + birthLabel: "生年月日", + notSet: "(未設定)", + lastLoginTimeLabel: "最終ログイン時間:", + lastLoginIpLabel: "最終ログイン IP:", + statusLabel: "状態", + editButton: "編集", + changePasswordButton: "パスワード変更", + deleteAccountButton: "アカウント削除", + successMsg: "プロフィール更新成功", + fetchUserInfoFailure: "ユーザー情報の取得に失敗しました", + avatarUploadFailure: "アバターのアップロードに失敗しました", + avatarUploadSuccess: "アバターが更新されました", + verified: "認証済み", + notVerified: "未認証", + resendVerification: "認証メール再送信", + changePasswordSuccess: "パスワード変更成功!", + passwordMismatch: "入力したパスワードが一致しません", + changePasswordFailureTitle: "パスワード変更失敗", + verificationModalTitle: "認証メール再送信", + verificationModalOk: "確定", + verificationModalCancel: "キャンセル", + verificationModalExampleEmail: "デフォルトのメール (example.com) は認証に使用できません。有効なメールに変更してください。", + verificationModalDesc: "システムは以下のメールに認証メールを送信します:{email}\nこのメールが正しいか確認してください。", + deleteAccountSuccess: "アカウントが削除されました。ホームページにリダイレクトします。", + saveButton: "保存", + cancelButton: "キャンセル", + }, +}; + export default function DashboardMyInfo() { + const { i18n: { currentLocale } } = useDocusaurusContext(); + const text = localeText[currentLocale] || localeText.en; + const { token, user, @@ -53,8 +174,8 @@ export default function DashboardMyInfo() { // 上次登入資訊 const lastLoginTime = user?.last_login_at ? moment(user.last_login_at).format("YYYY-MM-DD HH:mm") + " (UTC+0)" - : "無資料"; - const lastLoginIp = user?.last_login_ip || "無資料"; + : text.notSet; + const lastLoginIp = user?.last_login_ip || text.notSet; // =========== 1. 取得使用者資訊 =========== const refreshUserInfo = async () => { @@ -67,14 +188,14 @@ export default function DashboardMyInfo() { }, }); if (!res.ok) { - throw new Error("取得使用者資訊失敗"); + throw new Error(text.fetchUserInfoFailure); } const data = await res.json(); setUser(data); // 若 email 存在但未驗證,顯示警示 setShowEmailAlert(data.email && data.is_email_verified === false); } catch (err) { - message.error(err.message || "取得使用者資訊失敗"); + message.error(err.message || text.fetchUserInfoFailure); } finally { setInfoLoading(false); } @@ -113,7 +234,7 @@ export default function DashboardMyInfo() { : null; const payload = { ...values, birth: birthString }; const updatedUser = await updateProfile(payload); - message.success("個人資料更新成功"); + message.success(text.successMsg); setUser(updatedUser); setShowEmailAlert( updatedUser.email && updatedUser.is_email_verified === false @@ -123,7 +244,7 @@ export default function DashboardMyInfo() { if (err.message.includes("Email already exists")) { profileForm.setFields([{ name: "email", errors: [err.message] }]); } - message.error(err.message || "個人資料更新失敗"); + message.error(err.message || text.fetchUserInfoFailure); } }; @@ -144,16 +265,16 @@ export default function DashboardMyInfo() { }); if (!res.ok) { const errData = await res.json().catch(() => ({})); - throw new Error(errData.detail || "頭像上傳失敗"); + throw new Error(errData.detail || text.avatarUploadFailure); } const result = await res.json(); - message.success("頭像已更新"); + message.success(text.avatarUploadSuccess); setUser((prev) => ({ ...prev, avatar: result.avatar, })); } catch (err) { - message.error(err.message || "頭像上傳失敗"); + message.error(err.message || text.avatarUploadFailure); } finally { setUploadLoading(false); } @@ -169,18 +290,19 @@ export default function DashboardMyInfo() { const onChangePassword = async (values) => { if (values.newPassword !== values.confirmPassword) { - setErrorMessage("兩次輸入的密碼不一致"); + setErrorMessage(text.passwordMismatch); setErrorModalVisible(true); return; } try { await changePassword(values.oldPassword, values.newPassword); - message.success("密碼變更成功!"); + message.success(text.changePasswordSuccess); setPwdModalVisible(false); pwdForm.resetFields(); } catch (err) { console.error("變更密碼錯誤:", err); - const errorMsg = err.response?.data?.detail || err.message || "密碼變更失敗"; + const errorMsg = + err.response?.data?.detail || err.message || text.changePasswordFailureTitle; setErrorMessage(errorMsg); setErrorModalVisible(true); } @@ -195,7 +317,9 @@ export default function DashboardMyInfo() { const handleVerificationOk = async () => { try { await sendVerificationEmail(user.email); - message.success("驗證信已寄出,請檢查信箱"); + message.success( + text.verificationModalSent || "驗證信已寄出,請檢查信箱" + ); setVerificationModalVisible(false); } catch (err) { message.error(err.message || "寄送驗證信失敗"); @@ -206,7 +330,7 @@ export default function DashboardMyInfo() { const onDeleteAccount = async () => { try { await deleteAccount(); - message.success("帳號已刪除,將導回主頁"); + message.success(text.deleteAccountSuccess); setDeleteModalVisible(false); window.location.href = "/"; } catch (err) { @@ -220,16 +344,16 @@ export default function DashboardMyInfo() { const renderEmailStatus = () => { if (!user?.email) { - return 尚未設定; + return {text.notSet}; } if (user.is_email_verified) { - return 已驗證; + return {text.verified}; } return ( - 未驗證 + {text.notVerified} ); @@ -237,13 +361,13 @@ export default function DashboardMyInfo() { return (
-

我的資訊

+

{text.myInfoTitle}

{showEmailAlert && user?.email && !user.is_email_verified && ( @@ -252,14 +376,15 @@ export default function DashboardMyInfo() { {!user?.email && !editing && ( - 您尚未綁定 Email,請點 - - 補上 Email。 + {text.noEmailAlertDesc.replace( + "{editLink}", + + )} } type="info" @@ -277,13 +402,11 @@ export default function DashboardMyInfo() { onError={() => false} />
- +
@@ -303,27 +426,27 @@ export default function DashboardMyInfo() { }} > {user?.is_email_verified === false ? : } - + ({ value: value ? moment(value, "YYYY-MM-DD").startOf("day") : null, @@ -344,38 +467,45 @@ export default function DashboardMyInfo() { + - ) : (
-

帳號:{user?.username || "(未設定)"}

- Email:{user?.email || "(未設定)"} + {text.accountLabel}:{user?.username || text.notSet} +

+

+ {text.emailLabel}:{user?.email || text.notSet} {user?.email && ( - (狀態:{renderEmailStatus()}) + ({text.statusLabel}:{renderEmailStatus()}) )}

-

電話:{user?.phone || "(未設定)"}

- 生日: + {text.phoneLabel}:{user?.phone || text.notSet} +

+

+ {text.birthLabel}: {user?.birth ? moment(user.birth).format("YYYY-MM-DD") - : "(未設定)"} + : text.notSet}

- 上次登入時間: + {text.lastLoginTimeLabel} {lastLoginTime}
- 上次登入 IP:{lastLoginIp} + {text.lastLoginIpLabel} + {lastLoginIp}

)} @@ -387,12 +517,12 @@ export default function DashboardMyInfo() {
@@ -413,50 +543,87 @@ export default function DashboardMyInfo() { onDelete={onDeleteAccount} /> - {/* 狀態控制的驗證信 Modal */} + {/* 驗證信 Modal */} document.body} onOk={handleVerificationOk} onCancel={() => setVerificationModalVisible(false)} - okText="確定" - cancelText="取消" + okText={text.verificationModalOk} + cancelText={text.verificationModalCancel} okButtonProps={{ disabled: /@example\.com$/i.test(user?.email) }} > {/@example\.com$/i.test(user?.email) ? ( -

- 預設信箱 (example.com) 無法用於驗證,請更換為有效的 Email。 -

+

{text.verificationModalExampleEmail}

) : ( <> -

- 系統將發送驗證信至: - {user?.email} -

-

請確認此 Email 是否正確?

+

{text.verificationModalDesc.replace("{email}", user?.email)}

)}
setErrorModalVisible(false)} footer={[ + {text.changePasswordModalOk} + , ]} >

{errorMessage}

- ); } function ChangePasswordModal({ visible, onCancel, onSubmit, form }) { + const { i18n: { currentLocale } } = useDocusaurusContext(); + const localeText = { + "zh-hant": { + modalTitle: "變更密碼", + oldPasswordLabel: "舊密碼", + oldPasswordRequired: "請輸入舊密碼", + newPasswordLabel: "新密碼", + newPasswordRequired: "請輸入新密碼", + newPasswordTooShort: "至少 8 碼", + confirmNewPasswordLabel: "確認新密碼", + confirmNewPasswordRequired: "請再次輸入新密碼", + passwordMismatch: "兩次輸入的密碼不一致", + okText: "儲存", + cancelText: "取消", + }, + en: { + modalTitle: "Change Password", + oldPasswordLabel: "Old Password", + oldPasswordRequired: "Please enter your old password", + newPasswordLabel: "New Password", + newPasswordRequired: "Please enter your new password", + newPasswordTooShort: "At least 8 characters", + confirmNewPasswordLabel: "Confirm New Password", + confirmNewPasswordRequired: "Please re-enter your new password", + passwordMismatch: "Passwords do not match", + okText: "Save", + cancelText: "Cancel", + }, + ja: { + modalTitle: "パスワード変更", + oldPasswordLabel: "旧パスワード", + oldPasswordRequired: "旧パスワードを入力してください", + newPasswordLabel: "新パスワード", + newPasswordRequired: "新パスワードを入力してください", + newPasswordTooShort: "8文字以上である必要があります", + confirmNewPasswordLabel: "新パスワード確認", + confirmNewPasswordRequired: "新パスワードを再入力してください", + passwordMismatch: "入力したパスワードが一致しません", + okText: "保存", + cancelText: "キャンセル", + }, + }; + const text = localeText[currentLocale] || localeText.en; + const [passwordStrength, setPasswordStrength] = useState(""); const onFinish = (values) => { @@ -477,29 +644,29 @@ function ChangePasswordModal({ visible, onCancel, onSubmit, form }) { return ( { onCancel(); form.resetFields(); setPasswordStrength(""); }} onOk={() => form.submit()} - okText="儲存" - cancelText="取消" + okText={text.okText} + cancelText={text.cancelText} >
- 新密碼 + {text.newPasswordLabel} {passwordStrength && ( (強度:{passwordStrength}) @@ -509,14 +676,14 @@ function ChangePasswordModal({ visible, onCancel, onSubmit, form }) { } name="newPassword" rules={[ - { required: true, message: "請輸入新密碼" }, + { required: true, message: text.newPasswordRequired }, { validator: async (_, value) => { if (!value) { - return Promise.reject(new Error("請輸入新密碼")); + return Promise.reject(new Error(text.newPasswordRequired)); } if (value.length < 8) { - return Promise.reject(new Error("至少 8 碼")); + return Promise.reject(new Error(text.newPasswordTooShort)); } return Promise.resolve(); }, @@ -526,17 +693,17 @@ function ChangePasswordModal({ visible, onCancel, onSubmit, form }) { ({ validator(_, value) { if (!value || getFieldValue("newPassword") === value) { return Promise.resolve(); } - return Promise.reject("兩次輸入的密碼不一致"); + return Promise.reject(text.passwordMismatch); }, }), ]} @@ -549,17 +716,41 @@ function ChangePasswordModal({ visible, onCancel, onSubmit, form }) { } function DeleteAccountModal({ visible, onCancel, onDelete }) { + const { i18n: { currentLocale } } = useDocusaurusContext(); + const localeText = { + "zh-hant": { + modalTitle: "刪除帳號", + modalContent: "您確定要刪除帳號嗎?此操作無法復原!", + okText: "確定刪除", + cancelText: "取消", + }, + en: { + modalTitle: "Delete Account", + modalContent: + "Are you sure you want to delete your account? This action cannot be undone!", + okText: "Confirm Delete", + cancelText: "Cancel", + }, + ja: { + modalTitle: "アカウント削除", + modalContent: "本当にアカウントを削除しますか?この操作は元に戻せません!", + okText: "削除を確定", + cancelText: "キャンセル", + }, + }; + const text = localeText[currentLocale] || localeText.en; + return ( -

您確定要刪除帳號嗎?此操作無法復原!

+

{text.modalContent}

); } diff --git a/src/components/PasswordInput.js b/src/components/PasswordInput.js index e65d3cb44c0..69a8a03e805 100644 --- a/src/components/PasswordInput.js +++ b/src/components/PasswordInput.js @@ -1,4 +1,5 @@ // /src/components/PasswordInput.js +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { Input, Progress, Typography } from "antd"; import React, { useState } from "react"; import zxcvbn from "zxcvbn"; @@ -6,15 +7,33 @@ import zxcvbn from "zxcvbn"; const { Text } = Typography; export default function PasswordInput({ onChange, ...rest }) { + const { i18n: { currentLocale } } = useDocusaurusContext(); + + const localeText = { + "zh-hant": { + passwordStrengthTitle: "密碼強度:", + strengthTexts: ["非常弱", "弱", "中等", "強", "非常強"], + }, + en: { + passwordStrengthTitle: "Password Strength: ", + strengthTexts: ["Very Weak", "Weak", "Medium", "Strong", "Very Strong"], + }, + ja: { + passwordStrengthTitle: "パスワードの強度:", + strengthTexts: ["非常に弱い", "弱い", "普通", "強い", "非常に強い"], + }, + }; + + const text = localeText[currentLocale] || localeText.en; + const [score, setScore] = useState(0); - const strengthTexts = ["非常弱", "弱", "中等", "強", "非常強"]; const progressPercent = score * 25; const strokeColor = [ - "#ff4d4f", // 非常弱 - "#ff7a45", // 弱 - "#faad14", // 中等 - "#52c41a", // 強 - "#1677ff", // 非常強 + "#ff4d4f", // 非常弱 / Very Weak + "#ff7a45", // 弱 / Weak + "#faad14", // 中等 / Medium + "#52c41a", // 強 / Strong + "#1677ff", // 非常強 / Very Strong ][score] || "#ff4d4f"; const handleChange = (e) => { @@ -28,7 +47,7 @@ export default function PasswordInput({ onChange, ...rest }) {
- {`密碼強度:${strengthTexts[score]}`} + {`${text.passwordStrengthTitle}${text.strengthTexts[score]}`}
diff --git a/src/components/forms/LoginForm.js b/src/components/forms/LoginForm.js index dad93defeac..2b79c235358 100644 --- a/src/components/forms/LoginForm.js +++ b/src/components/forms/LoginForm.js @@ -1,8 +1,7 @@ // /components/forms/LoginForm.js import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { Alert, Button, Form, Input, Typography } from "antd"; -import React, { useState } from "react"; - +import React, { useEffect, useState } from "react"; const localeText = { "zh-hant": { @@ -13,6 +12,10 @@ const localeText = { loginBtn: "登入", forgotPassword: "忘記密碼?", loginSuccessMsg: "登入成功!", + loginRequestTooFrequent: "登入請求過於頻繁,請稍後再試", + maxAttemptsReached: "已達最大嘗試次數,請稍後再試", + wrongCredentials: "密碼錯誤,剩餘嘗試次數:", + countdownMessage: "請等待 {lockCountdown} 秒後再試", }, en: { usernameLabel: "Username", @@ -22,6 +25,10 @@ const localeText = { loginBtn: "Login", forgotPassword: "Forgot password?", loginSuccessMsg: "Login successful!", + loginRequestTooFrequent: "Too many login attempts, please try again later.", + maxAttemptsReached: "Maximum attempts reached, please try again later.", + wrongCredentials: "Incorrect password, remaining attempts: ", + countdownMessage: "Please wait {lockCountdown} seconds before trying again.", }, ja: { usernameLabel: "ユーザー名", @@ -31,11 +38,15 @@ const localeText = { loginBtn: "ログイン", forgotPassword: "パスワードをお忘れですか?", loginSuccessMsg: "ログイン成功!", + loginRequestTooFrequent: "ログインリクエストが頻繁すぎます。後でもう一度お試しください。", + maxAttemptsReached: "最大試行回数に達しました。後でもう一度お試しください。", + wrongCredentials: "ユーザー名またはパスワードが正しくありません。残り試行回数:", + countdownMessage: "{lockCountdown} 秒後に再試行してください。", }, }; export default function LoginForm({ - onLogin, // 呼叫後端 /auth/login 的函式 + onLogin, // 呼叫後端 /auth/login 的函式,回傳格式:{ success, errorMessage, status } onSuccess, // 登入成功後的 callback (可用來跳轉 / 關閉 Modal) loading, // 登入按鈕的加載狀態 onToggleForgotPassword // 切換到「忘記密碼」畫面的函式 (若需要) @@ -52,25 +63,70 @@ export default function LoginForm({ // antd form const [form] = Form.useForm(); + // 錯誤嘗試計數與鎖定狀態 + const [failedAttempts, setFailedAttempts] = useState(0); + const [isLocked, setIsLocked] = useState(false); + const MAX_ATTEMPTS = 5; // 可自訂最大嘗試次數 + const LOCK_DURATION = 900; // 預設鎖定時長(秒) + const [lockCountdown, setLockCountdown] = useState(0); + + // 當進入鎖定狀態時,啟動倒數計時 + useEffect(() => { + let timer; + if (isLocked) { + setLockCountdown(LOCK_DURATION); + timer = setInterval(() => { + setLockCountdown((prev) => { + if (prev <= 1) { + clearInterval(timer); + // 倒數結束,自動解鎖並重置失敗計數 + setIsLocked(false); + setFailedAttempts(0); + return 0; + } + return prev - 1; + }); + }, 1000); + } + return () => { + if (timer) clearInterval(timer); + }; + }, [isLocked]); + /** * 提交表單: - * 1. 清空錯誤、成功提示 - * 2. 呼叫 onLogin(username, password) - * 3. 若成功 => 顯示成功訊息 / 呼叫 onSuccess - * 4. 若失敗 => 顯示錯誤訊息 + * 1. 清空錯誤與成功提示 + * 2. 呼叫 onLogin(username, password),預期回傳 { success, errorMessage, status } + * 3. 若成功 => 顯示成功訊息 / 呼叫 onSuccess + * 4. 若失敗: + * - 若 status 為 429,表示後端正在封鎖,直接鎖定並顯示通用 ban 訊息 + * - 其他錯誤則累計失敗次數,達到上限時鎖定並啟動倒數,並只顯示通用錯誤訊息 */ const onFinish = async (values) => { + if (isLocked) return; // 鎖定狀態下不執行登入 setSubmitError(""); setSuccessMessage(""); - const ok = await onLogin(values.username, values.password); - if (ok) { - // 登入成功 + const result = await onLogin(values.username, values.password); + if (result.success) { setSuccessMessage(text.loginSuccessMsg); - onSuccess?.(); // 若需要跳轉 => 在父層做 window.location.href 或關閉 Modal + setFailedAttempts(0); + onSuccess?.(); } else { - // 登入失敗 - setSubmitError("帳號或密碼錯誤,請再試一次。"); + // 若回傳 429,表示後端封鎖中,顯示通用 ban 訊息 + if (result.status === 429) { + setIsLocked(true); + setSubmitError(text.loginRequestTooFrequent); + } else { + const newCount = failedAttempts + 1; + setFailedAttempts(newCount); + if (newCount >= MAX_ATTEMPTS) { + setIsLocked(true); + setSubmitError(text.maxAttemptsReached); + } else { + setSubmitError(`${text.wrongCredentials}${MAX_ATTEMPTS - newCount}`); + } + } } }; @@ -87,7 +143,7 @@ export default function LoginForm({ name="username" rules={[{ required: true, message: text.usernameError }]} > - +
{/* 密碼欄位 */} @@ -96,7 +152,7 @@ export default function LoginForm({ name="password" rules={[{ required: true, message: text.passwordRequired }]} > - + {/* 登入成功訊息 */} @@ -119,9 +175,25 @@ export default function LoginForm({ /> )} + {/* 若鎖定中,顯示倒數訊息 */} + {isLocked && ( + + )} + {/* 提交按鈕 */} - diff --git a/src/components/forms/RegisterForm.js b/src/components/forms/RegisterForm.js index afcfb5e9b12..d5ea5e522cc 100644 --- a/src/components/forms/RegisterForm.js +++ b/src/components/forms/RegisterForm.js @@ -16,8 +16,6 @@ const localeText = { successMsg: "註冊成功!", pwnedWarning: "此密碼曾出現在外洩紀錄中,請使用更安全的密碼。", passwordTooShort: "密碼長度需至少 8 碼", - passwordStrengthTitle: "密碼強度:", - strengthTexts: ["非常弱", "弱", "中等", "強", "非常強"], }, en: { usernameLabel: "Username", @@ -29,8 +27,6 @@ const localeText = { successMsg: "Registration successful!", pwnedWarning: "This password has appeared in data breaches. Please use a more secure one.", passwordTooShort: "Password must be at least 8 characters", - passwordStrengthTitle: "Password Strength: ", - strengthTexts: ["Very Weak", "Weak", "Medium", "Strong", "Very Strong"], }, ja: { usernameLabel: "ユーザー名", @@ -42,8 +38,6 @@ const localeText = { successMsg: "登録が完了しました!", pwnedWarning: "このパスワードは漏洩した履歴があります。より安全なものを使用してください。", passwordTooShort: "パスワードは8文字以上である必要があります", - passwordStrengthTitle: "パスワードの強度:", - strengthTexts: ["非常に弱い", "弱い", "普通", "強い", "非常に強い"], }, }; diff --git a/src/hooks/useAuthHandler.js b/src/hooks/useAuthHandler.js index 6c376f1c111..dd471db6ae7 100644 --- a/src/hooks/useAuthHandler.js +++ b/src/hooks/useAuthHandler.js @@ -1,8 +1,9 @@ // /src/hooks/useAuthHandler.js -import { message } from "antd"; // 若需要彈出提示 +import { message } from "antd"; import { useState } from "react"; import { useAuth } from "../context/AuthContext"; + export default function useAuthHandler() { const { loginSuccess } = useAuth(); const [loading, setLoading] = useState(false); @@ -18,35 +19,53 @@ export default function useAuthHandler() { try { const res = await fetch("https://api.docsaid.org/auth/login", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + }, body: JSON.stringify({ username, password }), }); + + let data; + try { + data = await res.json(); + } catch (err) { + data = {}; + } + + // 若非成功狀態,根據回傳內容整理錯誤訊息並回傳包含狀態碼的物件 if (!res.ok) { - const errData = await res.json().catch(() => ({})); - message.error(errData.detail || "登入失敗"); - return false; + let errorMsg = "登入失敗"; + if (data.detail) { + if (typeof data.detail === "object") { + errorMsg = Object.entries(data.detail) + .map(([key, value]) => `${key}: ${value}`) + .join(" | "); + } else { + errorMsg = data.detail; + } + } + console.error(`Error ${res.status}:`, data); + return { success: false, errorMessage: `Error ${res.status}: ${errorMsg}`, status: res.status }; } - // 成功 => 後端回傳 { access_token, token_type, ... } - const data = await res.json(); if (data.access_token) { - // 呼叫 AuthContext => 寫入 localStorage => 抓 user 資訊 await loginSuccess(data.access_token); message.success("登入成功"); - return true; + return { success: true, status: res.status }; } message.error("登入失敗: token 不存在"); - return false; + return { success: false, errorMessage: "登入失敗: token 不存在", status: res.status }; } catch (error) { console.error("login error:", error); message.error(error.message || "登入請求失敗"); - return false; + return { success: false, errorMessage: error.message || "登入請求失敗", status: 500 }; } finally { setLoading(false); } }; + /** * 註冊 * @param {{username: string, password: string, force?: boolean}} payload @@ -60,7 +79,9 @@ export default function useAuthHandler() { const res = await fetch("https://api.docsaid.org/auth/register", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + }, body: JSON.stringify({ username, password, diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index 328a4c0b2d3..489dace067f 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -9,7 +9,7 @@ import { PoweroffOutlined, UserOutlined, } from "@ant-design/icons"; -import Layout from "@theme/Layout"; // Docusaurus Layout +import Layout from "@theme/Layout"; import { Layout as AntLayout, Breadcrumb, diff --git a/src/pages/verify-email.js b/src/pages/verify-email.js deleted file mode 100644 index 4dce48661d8..00000000000 --- a/src/pages/verify-email.js +++ /dev/null @@ -1,40 +0,0 @@ -// src/pages/verify-email.js -import { useLocation } from "@docusaurus/router"; -import { message } from "antd"; -import React, { useEffect, useState } from "react"; - -// 假設你的 AuthContext 放在 src/context/AuthContext.js -import { useAuth } from "../context/AuthContext"; - -export default function VerifyEmailPage() { - const { verifyEmail } = useAuth(); // 你在 AuthContext 裡寫好的函式 - const location = useLocation(); // 取得目前路由資訊 (含 search) - const [status, setStatus] = useState("驗證中..."); - - useEffect(() => { - // 解析網址後面的 "?token=xxxx" - const searchParams = new URLSearchParams(location.search); - const token = searchParams.get("token"); - if (!token) { - setStatus("無效的連結,找不到 token"); - return; - } - - // 呼叫後端進行驗證 - verifyEmail({ token }) - .then(() => { - setStatus("Email 驗證成功!"); - message.success("Email 驗證成功,您可以使用完整功能囉!"); - }) - .catch((err) => { - setStatus(err.message || "驗證失敗"); - message.error(err.message || "驗證失敗"); - }); - }, [location, verifyEmail]); - - return ( -
-

{status}

-
- ); -} From fa636e97003addb603bd720aed69a35dc115c201 Mon Sep 17 00:00:00 2001 From: zephyr-sh Date: Mon, 10 Mar 2025 10:39:12 +0800 Subject: [PATCH 07/25] [C] Update i18n return msg --- src/components/AuthModal.js | 49 +++++-- src/components/forms/ForgotPasswordForm.js | 20 ++- src/components/forms/LoginForm.js | 79 ++++++----- src/context/AuthContext.js | 153 +++++++++++---------- src/hooks/useAuthHandler.js | 126 +++++++++++------ 5 files changed, 258 insertions(+), 169 deletions(-) diff --git a/src/components/AuthModal.js b/src/components/AuthModal.js index d39694e1448..1acf21734fa 100644 --- a/src/components/AuthModal.js +++ b/src/components/AuthModal.js @@ -1,4 +1,5 @@ // /src/components/AuthModal.js +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { Divider, Modal, Tabs, Typography } from "antd"; import React, { useState } from "react"; import useAuthHandler from "../hooks/useAuthHandler"; @@ -6,19 +7,42 @@ import ForgotPasswordForm from "./forms/ForgotPasswordForm"; import LoginForm from "./forms/LoginForm"; import RegisterForm from "./forms/RegisterForm"; +/** + * 簡易字典,可根據專案需求擴充 + */ +const i18nTexts = { + "zh-hant": { + modalTitle: "會員中心", + loginTab: "登入", + registerTab: "註冊", + backToLogin: "回到登入", + }, + en: { + modalTitle: "Member Center", + loginTab: "Login", + registerTab: "Register", + backToLogin: "Back to Login", + }, + ja: { + modalTitle: "会員センター", + loginTab: "ログイン", + registerTab: "新規登録", + backToLogin: "ログインに戻る", + }, +}; + export default function AuthModal({ visible, onCancel }) { - // 1. 從自訂 Hook 取得登入 / 註冊 函式與 loading 狀態 const { login, register, loading } = useAuthHandler(); - - // 2. 目前顯示的分頁 (login / register / forgotPassword) + const { + i18n: { currentLocale }, + } = useDocusaurusContext(); + const lang = currentLocale; + const texts = i18nTexts[lang] || i18nTexts.en; // 預設英語 const [mode, setMode] = useState("login"); - - // 3. 切換畫面 const goToForgotPassword = () => setMode("forgotPassword"); const goToLogin = () => setMode("login"); const goToRegister = () => setMode("register"); - // 4. 「登入」畫面內容 const renderLoginContent = () => ( ); - // 5. 「註冊」畫面內容 const renderRegisterContent = () => ( ); - // 6. 「忘記密碼」畫面內容 const renderForgotPasswordContent = () => ( <> @@ -47,12 +69,11 @@ export default function AuthModal({ visible, onCancel }) { style={{ cursor: "pointer", color: "#1890ff" }} onClick={goToLogin} > - 回到登入 + {texts.backToLogin} ); - // 7. 依照 mode 顯示不同內容 const renderContent = () => { switch (mode) { case "login": @@ -69,11 +90,9 @@ export default function AuthModal({ visible, onCancel }) { return ( {/* 如果當前是忘記密碼模式,就脫離 Tabs,獨立顯示 */} {mode === "forgotPassword" ? ( @@ -85,12 +104,12 @@ export default function AuthModal({ visible, onCancel }) { items={[ { key: "login", - label: "登入", + label: texts.loginTab, // <-- 動態顯示「登入」 children: renderLoginContent(), }, { key: "register", - label: "註冊", + label: texts.registerTab, // <-- 動態顯示「註冊」 children: renderRegisterContent(), }, ]} diff --git a/src/components/forms/ForgotPasswordForm.js b/src/components/forms/ForgotPasswordForm.js index a1a5acf1443..cf0a5fc55d6 100644 --- a/src/components/forms/ForgotPasswordForm.js +++ b/src/components/forms/ForgotPasswordForm.js @@ -1,6 +1,5 @@ import { Button, Form, Input, message } from "antd"; import React, { useState } from "react"; -import { forgotPasswordApi } from "../../utils/mockApi"; export default function ForgotPasswordForm({ onSuccess }) { const [loading, setLoading] = useState(false); @@ -8,9 +7,24 @@ export default function ForgotPasswordForm({ onSuccess }) { const onFinish = async (values) => { setLoading(true); try { - await forgotPasswordApi(values.email); + // 呼叫後端的 /forgot-password API + const res = await fetch("https://api.docsaid.org/auth/forgot-password", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + // 後端預期接收的欄位是 "username", + // 這裡以表單填入的 email 當作 username 傳遞 + body: JSON.stringify({ username: values.email }), + }); + if (!res.ok) { + // 若回傳非 2xx,就嘗試解析回傳訊息並拋出錯誤 + const data = await res.json(); + throw new Error(data.detail || "寄送失敗,請稍後再試"); + } + // API 正常回傳時,即視為成功 message.success("重設密碼信已寄出,請檢查您的信箱!"); - onSuccess?.(); // 成功後關閉 Modal,或跳轉其它狀態 + onSuccess?.(); // 成功後關閉 Modal 或做其他操作 } catch (error) { message.error(error.message || "寄送失敗,請稍後再試"); } finally { diff --git a/src/components/forms/LoginForm.js b/src/components/forms/LoginForm.js index 2b79c235358..949c48a37ca 100644 --- a/src/components/forms/LoginForm.js +++ b/src/components/forms/LoginForm.js @@ -15,6 +15,7 @@ const localeText = { loginRequestTooFrequent: "登入請求過於頻繁,請稍後再試", maxAttemptsReached: "已達最大嘗試次數,請稍後再試", wrongCredentials: "密碼錯誤,剩餘嘗試次數:", + userNotFound: "使用者不存在", countdownMessage: "請等待 {lockCountdown} 秒後再試", }, en: { @@ -28,6 +29,7 @@ const localeText = { loginRequestTooFrequent: "Too many login attempts, please try again later.", maxAttemptsReached: "Maximum attempts reached, please try again later.", wrongCredentials: "Incorrect password, remaining attempts: ", + userNotFound: "User does not exist", countdownMessage: "Please wait {lockCountdown} seconds before trying again.", }, ja: { @@ -38,39 +40,38 @@ const localeText = { loginBtn: "ログイン", forgotPassword: "パスワードをお忘れですか?", loginSuccessMsg: "ログイン成功!", - loginRequestTooFrequent: "ログインリクエストが頻繁すぎます。後でもう一度お試しください。", + loginRequestTooFrequent: + "ログインリクエストが頻繁すぎます。後でもう一度お試しください。", maxAttemptsReached: "最大試行回数に達しました。後でもう一度お試しください。", wrongCredentials: "ユーザー名またはパスワードが正しくありません。残り試行回数:", + userNotFound: "ユーザーが存在しません", countdownMessage: "{lockCountdown} 秒後に再試行してください。", }, }; export default function LoginForm({ - onLogin, // 呼叫後端 /auth/login 的函式,回傳格式:{ success, errorMessage, status } - onSuccess, // 登入成功後的 callback (可用來跳轉 / 關閉 Modal) - loading, // 登入按鈕的加載狀態 - onToggleForgotPassword // 切換到「忘記密碼」畫面的函式 (若需要) + onLogin, // 從 useAuthHandler 傳進來, { success, errorMessage, status, userNotFound, remainingAttempts? } + onSuccess, + loading, + onToggleForgotPassword, }) { const { i18n: { currentLocale }, } = useDocusaurusContext(); const text = localeText[currentLocale] || localeText.en; - // 顯示錯誤與成功提示 + const [form] = Form.useForm(); const [submitError, setSubmitError] = useState(""); const [successMessage, setSuccessMessage] = useState(""); - // antd form - const [form] = Form.useForm(); - - // 錯誤嘗試計數與鎖定狀態 + // 錯誤嘗試計數 & 鎖定 const [failedAttempts, setFailedAttempts] = useState(0); const [isLocked, setIsLocked] = useState(false); - const MAX_ATTEMPTS = 5; // 可自訂最大嘗試次數 - const LOCK_DURATION = 900; // 預設鎖定時長(秒) + + const MAX_ATTEMPTS = 5; // 自訂最大嘗試次數 + const LOCK_DURATION = 900; // 鎖定秒數 const [lockCountdown, setLockCountdown] = useState(0); - // 當進入鎖定狀態時,啟動倒數計時 useEffect(() => { let timer; if (isLocked) { @@ -79,7 +80,6 @@ export default function LoginForm({ setLockCountdown((prev) => { if (prev <= 1) { clearInterval(timer); - // 倒數結束,自動解鎖並重置失敗計數 setIsLocked(false); setFailedAttempts(0); return 0; @@ -93,17 +93,9 @@ export default function LoginForm({ }; }, [isLocked]); - /** - * 提交表單: - * 1. 清空錯誤與成功提示 - * 2. 呼叫 onLogin(username, password),預期回傳 { success, errorMessage, status } - * 3. 若成功 => 顯示成功訊息 / 呼叫 onSuccess - * 4. 若失敗: - * - 若 status 為 429,表示後端正在封鎖,直接鎖定並顯示通用 ban 訊息 - * - 其他錯誤則累計失敗次數,達到上限時鎖定並啟動倒數,並只顯示通用錯誤訊息 - */ const onFinish = async (values) => { - if (isLocked) return; // 鎖定狀態下不執行登入 + if (isLocked) return; // 已鎖定,不允許登入 + setSubmitError(""); setSuccessMessage(""); @@ -113,13 +105,36 @@ export default function LoginForm({ setFailedAttempts(0); onSuccess?.(); } else { - // 若回傳 429,表示後端封鎖中,顯示通用 ban 訊息 + // 先檢查是否為 429 => 後端封鎖 if (result.status === 429) { setIsLocked(true); setSubmitError(text.loginRequestTooFrequent); + return; + } + + // 檢查是否使用者不存在 + if (result.userNotFound) { + // 不進行失敗次數紀錄,直接顯示「使用者不存在」 + setSubmitError(text.userNotFound); + return; + } + + // 其餘錯誤 (包含密碼錯誤) + // 如果後端帶有 remainingAttempts,則跟著更新 + if (typeof result.remainingAttempts === "number") { + const rem = result.remainingAttempts; + if (rem <= 0) { + // 若後端表示剩餘次數0 => 也視為封鎖 + setIsLocked(true); + setSubmitError(text.maxAttemptsReached); + } else { + setSubmitError(`${text.wrongCredentials}${rem}`); + } } else { + // 若後端沒有帶 remainingAttempts,則用本地邏輯做簡單累計 const newCount = failedAttempts + 1; setFailedAttempts(newCount); + if (newCount >= MAX_ATTEMPTS) { setIsLocked(true); setSubmitError(text.maxAttemptsReached); @@ -137,7 +152,6 @@ export default function LoginForm({ onFinish={onFinish} style={{ maxWidth: 400, margin: "0 auto" }} > - {/* 帳號欄位 */} - {/* 密碼欄位 */} - {/* 登入成功訊息 */} {successMessage && ( )} - - {/* 錯誤訊息 */} {submitError && ( )} - - {/* 若鎖定中,顯示倒數訊息 */} {isLocked && ( )} - {/* 提交按鈕 */} diff --git a/src/components/forms/ResetPasswordForm.js b/src/components/forms/ResetPasswordForm.js new file mode 100644 index 00000000000..0108d38e753 --- /dev/null +++ b/src/components/forms/ResetPasswordForm.js @@ -0,0 +1,228 @@ +// /src/components/forms/ResetPasswordForm.js +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; +import { Alert, Button, Form, Modal, Typography } from "antd"; +import React, { useEffect, useState } from "react"; +import PasswordInput from "../PasswordInput"; + +const { Text } = Typography; + +const localeText = { + "zh-hant": { + newPasswordLabel: "新密碼", + newPasswordRequired: "請輸入新密碼", + confirmPasswordLabel: "確認新密碼", + confirmPasswordRequired: "請再次輸入新密碼", + passwordMismatch: "兩次輸入的密碼不一致", + passwordTooShort: "密碼長度需至少 8 碼", + resetBtn: "重設密碼", + successMsg: "密碼重設成功!", + errorMsg: "密碼重設失敗,請稍後再試", + missingToken: "無效或缺少重設密碼 token", + errorModalTitle: "密碼重設失敗", + errorModalOk: "確定", + passwordStrengthLabel: "密碼強度:", + strengthTexts: ["非常弱", "弱", "中等", "強", "非常強"], + }, + en: { + newPasswordLabel: "New Password", + newPasswordRequired: "Please enter your new password", + confirmPasswordLabel: "Confirm New Password", + confirmPasswordRequired: "Please confirm your new password", + passwordMismatch: "The two passwords do not match", + passwordTooShort: "Password must be at least 8 characters", + resetBtn: "Reset Password", + successMsg: "Password reset successfully!", + errorMsg: "Failed to reset password, please try again later", + missingToken: "Missing or invalid reset token", + errorModalTitle: "Reset Password Failed", + errorModalOk: "OK", + passwordStrengthLabel: "Password Strength: ", + strengthTexts: ["Very Weak", "Weak", "Medium", "Strong", "Very Strong"], + }, + ja: { + newPasswordLabel: "新しいパスワード", + newPasswordRequired: "新しいパスワードを入力してください", + confirmPasswordLabel: "新しいパスワードの確認", + confirmPasswordRequired: "もう一度新しいパスワードを入力してください", + passwordMismatch: "入力されたパスワードが一致しません", + passwordTooShort: "パスワードは8文字以上である必要があります", + resetBtn: "パスワードをリセット", + successMsg: "パスワードのリセットに成功しました!", + errorMsg: "パスワードのリセットに失敗しました。後でもう一度お試しください", + missingToken: "リセットトークンが無効か不足しています", + errorModalTitle: "パスワードリセット失敗", + errorModalOk: "確定", + passwordStrengthLabel: "Password Strength: ", + strengthTexts: ["Very Weak", "Weak", "Medium", "Strong", "Very Strong"], + }, +}; + +export default function ResetPasswordForm({ onSuccess }) { + const { i18n: { currentLocale } } = useDocusaurusContext(); + const text = localeText[currentLocale] || localeText.en; + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [successMessage, setSuccessMessage] = useState(""); + // 錯誤訊息以 Modal 呈現 + const [errorModalVisible, setErrorModalVisible] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + // 從 URL 提取 token + const [token, setToken] = useState(null); + // 密碼強度分數,僅用於新密碼欄位 + const [passwordScore, setPasswordScore] = useState(0); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const tokenFromUrl = params.get("token"); + setToken(tokenFromUrl); + }, []); + + // 新密碼欄位 onChange 以計算密碼強度 + const handlePasswordChange = (e) => { + const value = e.target.value; + if (!value) { + setPasswordScore(0); + } else if (value.length < 6) { + setPasswordScore(1); + } else if (value.length < 10) { + setPasswordScore(2); + } else if (value.length < 14) { + setPasswordScore(3); + } else { + setPasswordScore(4); + } + }; + + // 客戶端密碼驗證規則 + const passwordRules = [ + { + required: true, + message: text.newPasswordRequired, + }, + { + validator: async (_, value) => { + if (value && value.length < 8) { + return Promise.reject(new Error(text.passwordTooShort)); + } + return Promise.resolve(); + }, + }, + ]; + + const onFinish = async (values) => { + setSuccessMessage(""); + setErrorMessage(""); + if (!token) { + setErrorMessage(text.missingToken); + setErrorModalVisible(true); + return; + } + setLoading(true); + try { + const res = await fetch("https://api.docsaid.org/auth/reset-password", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept-Language": currentLocale, + }, + body: JSON.stringify({ + token: token, + new_password: values.newPassword, + }), + }); + if (!res.ok) { + const data = await res.json(); + console.log("Error response:", data); + const errMsg = + data.error || + (data.detail && typeof data.detail === "object" ? data.detail.error : data.detail) || + text.errorMsg; + throw new Error(errMsg); + } + await res.json(); + setSuccessMessage(text.successMsg); + form.resetFields(); + if (onSuccess) { + onSuccess(); + } + setTimeout(() => { + window.location.href = "/"; + }, 1500); + } catch (error) { + console.error("Reset password error:", error); + setErrorMessage(error.message || text.errorMsg); + setErrorModalVisible(true); + } finally { + setLoading(false); + } + }; + + return ( + <> +
+ {successMessage && ( + + )} + + + + {passwordScore > 0 && ( + + {text.passwordStrengthLabel}{text.strengthTexts[passwordScore]} + + )} + ({ + validator(_, value) { + if (!value || getFieldValue("newPassword") === value) { + return Promise.resolve(); + } + return Promise.reject(new Error(text.passwordMismatch)); + }, + }), + ]} + > + + + + + + + + {/* 錯誤訊息 Modal */} + setErrorModalVisible(false)} + footer={[ + , + ]} + > +

{errorMessage}

+
+ + ); +} diff --git a/src/pages/reset-password.js b/src/pages/reset-password.js new file mode 100644 index 00000000000..403d5d1eb40 --- /dev/null +++ b/src/pages/reset-password.js @@ -0,0 +1,14 @@ +// src/pages/reset-password.js +import Layout from '@theme/Layout'; +import React from "react"; +import ResetPasswordForm from "../components/forms/ResetPasswordForm"; + +export default function ResetPasswordPage() { + return ( + +
+ +
+
+ ); +} From 7134b915b3050f33c37f677639e245e154fe6afd Mon Sep 17 00:00:00 2001 From: zephyr-sh Date: Mon, 10 Mar 2025 15:38:33 +0800 Subject: [PATCH 09/25] [C] Update email verified message --- src/components/Dashboard/MyInfo/index.js | 3 + src/pages/email-verified-failed.js | 85 ++++++++++++++++++++++++ src/pages/email-verified-failed.md | 21 ------ src/pages/email-verified-success.js | 83 +++++++++++++++++++++++ src/pages/email-verified-success.md | 17 ----- 5 files changed, 171 insertions(+), 38 deletions(-) create mode 100644 src/pages/email-verified-failed.js delete mode 100644 src/pages/email-verified-failed.md create mode 100644 src/pages/email-verified-success.js delete mode 100644 src/pages/email-verified-success.md diff --git a/src/components/Dashboard/MyInfo/index.js b/src/components/Dashboard/MyInfo/index.js index c52f527f096..f711b781604 100644 --- a/src/components/Dashboard/MyInfo/index.js +++ b/src/components/Dashboard/MyInfo/index.js @@ -62,6 +62,7 @@ const localeText = { deleteAccountSuccess: "帳號已刪除,將導回首頁", saveButton: "儲存", cancelButton: "取消", + changePasswordModalOk: "確定", }, en: { myInfoTitle: "My Information", @@ -100,6 +101,7 @@ const localeText = { deleteAccountSuccess: "Account deleted, redirecting to homepage", saveButton: "Save", cancelButton: "Cancel", + changePasswordModalOk: "Confirm", }, ja: { myInfoTitle: "私の情報", @@ -138,6 +140,7 @@ const localeText = { deleteAccountSuccess: "アカウントが削除されました。ホームページにリダイレクトします。", saveButton: "保存", cancelButton: "キャンセル", + changePasswordModalOk: "確定", }, }; diff --git a/src/pages/email-verified-failed.js b/src/pages/email-verified-failed.js new file mode 100644 index 00000000000..4352d7d518e --- /dev/null +++ b/src/pages/email-verified-failed.js @@ -0,0 +1,85 @@ +// src/pages/email-verified-failed.js +import { useHistory } from '@docusaurus/router'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import Layout from '@theme/Layout'; +import { Button, Card, Typography } from 'antd'; +import React from 'react'; + +const { Title, Paragraph } = Typography; + +const localeText = { + "zh-hant": { + title: "驗證失敗", + subtitle: "您的 Email 驗證未成功", + description: "可能原因包括驗證連結已過期或無效。請嘗試重新寄送驗證信,或聯繫客服以獲得協助。", + homeButton: "返回首頁", + resendButton: "重新寄送驗證信", + }, + en: { + title: "Email Verification Failed", + subtitle: "Your email verification was not successful", + description: "This may be due to an expired or invalid verification link. Please try resending the verification email or contact support for assistance.", + homeButton: "Return to Homepage", + resendButton: "Resend Verification Email", + }, + ja: { + title: "メール認証に失敗しました", + subtitle: "メール認証が正常に完了しませんでした", + description: "リンクの有効期限が切れているか、無効な可能性があります。認証メールの再送信を試すか、サポートにお問い合わせください。", + homeButton: "ホームへ戻る", + resendButton: "認証メールを再送信", + }, +}; + +export default function EmailVerifiedFailed() { + const { i18n: { currentLocale } } = useDocusaurusContext(); + const text = localeText[currentLocale] || localeText.en; + const history = useHistory(); + + // 根據語系設定首頁導向路徑 + let homePath = '/'; + if (currentLocale === 'en') { + homePath = '/en'; + } else if (currentLocale === 'ja') { + homePath = '/ja'; + } + + const handleGoHome = () => { + history.push(homePath); + }; + + return ( + +
+ + {text.title} + {text.subtitle} + + {text.description} + +
+ +
+
+
+
+ ); +} diff --git a/src/pages/email-verified-failed.md b/src/pages/email-verified-failed.md deleted file mode 100644 index d6349c8b877..00000000000 --- a/src/pages/email-verified-failed.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -id: email-verified-fail ---- - -# Email 驗證失敗 ⚠️ - -很抱歉,您的信箱驗證連結無效或已過期,原因可能是: - -1. 連結已過期,請再次透過帳號設定頁面重新發送驗證信。 -2. 使用者帳號已驗證過,無需再次驗證。 -3. 使用者不存在,請確認您的帳號是否正確。 - -請再次透過帳號設定頁面重新發送驗證信。 - -若持續遇到問題,請立即聯繫我們的客服團隊,我們將竭誠為您服務: - -- **客服信箱**:docsaidlab@gmail.com - -謝謝您的耐心與合作! - -DocSaid 團隊敬上 diff --git a/src/pages/email-verified-success.js b/src/pages/email-verified-success.js new file mode 100644 index 00000000000..48464bc7c2e --- /dev/null +++ b/src/pages/email-verified-success.js @@ -0,0 +1,83 @@ +// src/pages/email-verified-success.js +import { useHistory } from '@docusaurus/router'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import Layout from '@theme/Layout'; +import { Button, Card, Typography } from 'antd'; +import React from 'react'; + +const { Title, Paragraph } = Typography; + +const localeText = { + "zh-hant": { + title: "驗證成功!", + subtitle: "您的 Email 已成功驗證", + description: "現在您可以安全登入並享受我們提供的各項服務。", + buttonText: "返回首頁", + }, + en: { + title: "Email Verified Successfully", + subtitle: "Your email has been successfully verified", + description: "You can now safely log in and enjoy our services.", + buttonText: "Return to Homepage", + }, + ja: { + title: "メール認証に成功しました!", + subtitle: "あなたのメールは正常に認証されました", + description: "今すぐ安全にログインして、私たちのサービスをお楽しみください。", + buttonText: "ホームへ戻る", + }, +}; + +export default function EmailVerifiedSuccess() { + const { i18n: { currentLocale } } = useDocusaurusContext(); + const text = localeText[currentLocale] || localeText.en; + const history = useHistory(); + + // 根據語系決定首頁路徑 + let homePath = '/'; + if (currentLocale === 'en') { + homePath = '/en'; + } else if (currentLocale === 'ja') { + homePath = '/ja'; + } + + const handleGoHome = () => { + history.push(homePath); + }; + + return ( + +
+ + {text.title} + + {text.subtitle} + + + {text.description} + + + +
+
+ ); +} diff --git a/src/pages/email-verified-success.md b/src/pages/email-verified-success.md deleted file mode 100644 index 90967cf70ba..00000000000 --- a/src/pages/email-verified-success.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -id: email-verified-success ---- - -# Email 驗證成功 🎉 - -恭喜您,您的 Email 已成功驗證! - -您現在可以盡情享受 DocSaid 提供的所有功能。 - -若有任何問題,歡迎隨時與我們的客服團隊聯絡: - -- **客服信箱**:docsaidlab@gmail.com - -感謝您的支持與使用! - -DocSaid 團隊敬上 From b5485a5daf0db403d854162d312c29e495c804c8 Mon Sep 17 00:00:00 2001 From: zephyr-sh Date: Mon, 10 Mar 2025 15:53:23 +0800 Subject: [PATCH 10/25] [C] Update css settings --- src/components/Dashboard/MyInfo/index.js | 44 ++++--- src/components/forms/RegisterForm.js | 11 +- src/components/forms/ResetPasswordForm.js | 11 +- src/context/AuthContext.js | 10 +- src/pages/dashboard.js | 150 ++++++++++++++++++---- 5 files changed, 181 insertions(+), 45 deletions(-) diff --git a/src/components/Dashboard/MyInfo/index.js b/src/components/Dashboard/MyInfo/index.js index f711b781604..1b93e4e1275 100644 --- a/src/components/Dashboard/MyInfo/index.js +++ b/src/components/Dashboard/MyInfo/index.js @@ -396,24 +396,32 @@ export default function DashboardMyInfo() { )} -
-
- } - onError={() => false} - /> -
- - - -
- + +
+ } + onError={() => false} + /> +
+ + + +
+ {editing ? ( diff --git a/src/components/forms/RegisterForm.js b/src/components/forms/RegisterForm.js index d5ea5e522cc..5d7e22c3afa 100644 --- a/src/components/forms/RegisterForm.js +++ b/src/components/forms/RegisterForm.js @@ -80,7 +80,16 @@ export default function RegisterForm({ onLogin, onSuccess, onRegister, loading } if (ok) { onSuccess?.(); } - window.location.href = "/dashboard"; + + // 根據語系決定路徑 + let dashboardPath = '/dashboard'; + if (currentLocale === 'en') { + dashboardPath = '/en/dashboard'; + } else if (currentLocale === 'ja') { + dashboardPath = '/ja/dashboard'; + } + + window.location.href = dashboardPath; } else { if (result.pwned) { setSubmitError(text.pwnedWarning); diff --git a/src/components/forms/ResetPasswordForm.js b/src/components/forms/ResetPasswordForm.js index 0108d38e753..9394208ab22 100644 --- a/src/components/forms/ResetPasswordForm.js +++ b/src/components/forms/ResetPasswordForm.js @@ -145,8 +145,17 @@ export default function ResetPasswordForm({ onSuccess }) { if (onSuccess) { onSuccess(); } + + // 根據語系決定首頁路徑 + let homePath = '/'; + if (currentLocale === 'en') { + homePath = '/en'; + } else if (currentLocale === 'ja') { + homePath = '/ja'; + } + setTimeout(() => { - window.location.href = "/"; + window.location.href = homePath; }, 1500); } catch (error) { console.error("Reset password error:", error); diff --git a/src/context/AuthContext.js b/src/context/AuthContext.js index 446449c742a..a6ee5ec7214 100644 --- a/src/context/AuthContext.js +++ b/src/context/AuthContext.js @@ -122,12 +122,20 @@ export function AuthProvider({ children }) { } }; + // 根據語系決定首頁路徑 + let homePath = '/'; + if (currentLocale === 'en') { + homePath = '/en'; + } else if (currentLocale === 'ja') { + homePath = '/ja'; + } + // 登出 const logout = () => { setToken(null); setUser(null); localStorage.removeItem("token"); - window.location.href = "/"; + window.location.href = homePath; }; // 更新個人資料 diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index 489dace067f..41428882fd1 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -9,6 +9,7 @@ import { PoweroffOutlined, UserOutlined, } from "@ant-design/icons"; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import Layout from "@theme/Layout"; import { Layout as AntLayout, @@ -33,7 +34,102 @@ import DashboardMyInfo from "../components/Dashboard/MyInfo"; const { Header: AntHeader, Sider, Content, Footer } = AntLayout; +const localeText = { + "zh-hant": { + dashboardTitle: "我的後台", + loginWarning: "請先登入", + notLoggedIn: "尚未登入", + sider: { + collapsed: "後台", + expanded: "我的後台", + menu: { + myinfo: "我的資訊", + comments: "我的留言", + apikey: "我的 API Key", + apiusage: "API 使用紀錄", + }, + }, + breadcrumb: { + dashboard: "我的後台", + myinfo: "我的資訊", + comments: "我的留言", + apikey: "我的 API Key", + apiusage: "API 使用紀錄", + undefined: "未定義", + }, + userMenu: { + backHome: "回主站", + logout: "登出", + }, + footer: { + text: "© {year} My Company. All rights reserved.", + }, + }, + en: { + dashboardTitle: "Dashboard", + loginWarning: "Please log in first", + notLoggedIn: "Not logged in", + sider: { + collapsed: "Dashboard", + expanded: "My Dashboard", + menu: { + myinfo: "My Information", + comments: "My Comments", + apikey: "My API Key", + apiusage: "API Usage", + }, + }, + breadcrumb: { + dashboard: "Dashboard", + myinfo: "My Information", + comments: "My Comments", + apikey: "My API Key", + apiusage: "API Usage", + undefined: "Undefined", + }, + userMenu: { + backHome: "Back to Site", + logout: "Logout", + }, + footer: { + text: "© {year} My Company. All rights reserved.", + }, + }, + ja: { + dashboardTitle: "ダッシュボード", + loginWarning: "まずログインしてください", + notLoggedIn: "ログインしていません", + sider: { + collapsed: "ダッシュボード", + expanded: "マイダッシュボード", + menu: { + myinfo: "マイ情報", + comments: "マイコメント", + apikey: "マイAPIキー", + apiusage: "API利用状況", + }, + }, + breadcrumb: { + dashboard: "ダッシュボード", + myinfo: "マイ情報", + comments: "マイコメント", + apikey: "マイAPIキー", + apiusage: "API利用状況", + undefined: "未定義", + }, + userMenu: { + backHome: "サイトへ戻る", + logout: "ログアウト", + }, + footer: { + text: "© {year} My Company. All rights reserved.", + }, + }, +}; + export default function DashboardPage() { + const { i18n: { currentLocale } } = useDocusaurusContext(); + const text = localeText[currentLocale] || localeText.en; const { token, user, loading, logout } = useAuth(); const [selectedKey, setSelectedKey] = useState("myinfo"); const [collapsed, setCollapsed] = useState(false); @@ -42,10 +138,10 @@ export default function DashboardPage() { useEffect(() => { if (!loading && !token) { - message.warning("請先登入"); + message.warning(text.loginWarning); // 可根據需求導向登入頁面,例如:window.location.href = "/"; } - }, [loading, token]); + }, [loading, token, text.loginWarning]); const contentComponent = useMemo(() => { switch (selectedKey) { @@ -65,45 +161,51 @@ export default function DashboardPage() { const pageTitle = useMemo(() => { switch (selectedKey) { case "myinfo": - return "我的資訊"; + return text.breadcrumb.myinfo; case "comments": - return "我的留言"; + return text.breadcrumb.comments; case "apikey": - return "我的 API Key"; + return text.breadcrumb.apikey; case "apiusage": - return "API 使用紀錄"; + return text.breadcrumb.apiusage; default: - return "未定義"; + return text.breadcrumb.undefined; } - }, [selectedKey]); + }, [selectedKey, text.breadcrumb]); + + // 根據語系決定首頁路徑 + let homePath = '/'; + if (currentLocale === 'en') { + homePath = '/en'; + } else if (currentLocale === 'ja') { + homePath = '/ja'; + } - // 建立右上角用戶選單,使用新版 Dropdown API const userMenuItems = [ { key: "backHome", icon: , - label: "回主站", + label: text.userMenu.backHome, onClick: () => { - window.location.href = "/"; + window.location.href = homePath; }, }, { key: "logout", icon: , - label: "登出", + label: text.userMenu.logout, onClick: logout, }, ]; - // 使用新版 Breadcrumb API const breadcrumbItems = [ - { title: "我的後台" }, + { title: text.breadcrumb.dashboard }, { title: pageTitle }, ]; if (loading) { return ( - + ); @@ -111,16 +213,16 @@ export default function DashboardPage() { if (!token) { return ( - +
-

尚未登入

+

{text.notLoggedIn}

); } return ( - + - {collapsed ? "後台" : "我的後台"} + {collapsed ? text.sider.collapsed : text.sider.expanded} setSelectedKey(e.key)} items={[ - { key: "myinfo", icon: , label: "我的資訊" }, - { key: "comments", icon: , label: "我的留言" }, - { key: "apikey", icon: , label: "我的 API Key" }, - { key: "apiusage", icon: , label: "API 使用紀錄" }, + { key: "myinfo", icon: , label: text.sider.menu.myinfo }, + { key: "comments", icon: , label: text.sider.menu.comments }, + { key: "apikey", icon: , label: text.sider.menu.apikey }, + { key: "apiusage", icon: , label: text.sider.menu.apiusage }, ]} /> @@ -196,7 +298,7 @@ export default function DashboardPage() {
- © {new Date().getFullYear()} My Company. All rights reserved. + {text.footer.text.replace("{year}", new Date().getFullYear())}
From d811e2701834f915fa0035f1c7d5124bbc8ddb63 Mon Sep 17 00:00:00 2001 From: zephyr-sh Date: Tue, 11 Mar 2025 20:43:08 +0800 Subject: [PATCH 11/25] [R] Remove comment page --- src/components/Dashboard/MyComments/index.js | 259 ------------------- src/pages/dashboard.js | 13 - 2 files changed, 272 deletions(-) delete mode 100644 src/components/Dashboard/MyComments/index.js diff --git a/src/components/Dashboard/MyComments/index.js b/src/components/Dashboard/MyComments/index.js deleted file mode 100644 index 082647b6c51..00000000000 --- a/src/components/Dashboard/MyComments/index.js +++ /dev/null @@ -1,259 +0,0 @@ -// 檔案: src/components/dashboard/DashboardMyComments.jsx -import { - Button, - Card, - Col, - DatePicker, - Form, - Input, - message, - Modal, - Row, - Space, - Spin, - Table, -} from "antd"; -import moment from "moment"; -import React, { useEffect, useState } from "react"; -import { - deleteCommentApi, - getMyCommentsApi, - updateCommentApi, -} from "../../../utils/mockApi"; - -const { RangePicker } = DatePicker; - -export default function DashboardMyComments() { - const [loading, setLoading] = useState(false); - const [comments, setComments] = useState([]); - - // 搜尋關鍵字 - const [searchText, setSearchText] = useState(""); - // 建立日期篩選 (start ~ end) - const [dateRange, setDateRange] = useState([]); - // 批次刪除多選 - const [selectedRowKeys, setSelectedRowKeys] = useState([]); - - // 編輯留言 Modal - const [editModalVisible, setEditModalVisible] = useState(false); - const [editingComment, setEditingComment] = useState(null); - - // 初次載入 or 重新整理 → 取得留言 - useEffect(() => { - fetchComments(); - }, []); - - const fetchComments = async () => { - setLoading(true); - try { - const data = await getMyCommentsApi(); - setComments(data); - } catch (err) { - message.error(err.message || "取得留言失敗"); - } finally { - setLoading(false); - } - }; - - // 單筆:開啟「編輯 Modal」 - const handleEdit = (record) => { - setEditingComment({ ...record }); - setEditModalVisible(true); - }; - - // 單筆:執行刪除 - const handleDelete = async (id) => { - try { - await deleteCommentApi(id); - message.success("留言已刪除"); - setComments((prev) => prev.filter((item) => item.id !== id)); - } catch (err) { - message.error(err.message || "刪除失敗"); - } - }; - - // 編輯後:儲存變更 - const handleSaveComment = async (values) => { - try { - await updateCommentApi(values.id, values.content); - message.success("留言已更新"); - setComments((prev) => - prev.map((c) => (c.id === values.id ? { ...c, ...values } : c)) - ); - setEditModalVisible(false); - } catch (err) { - message.error(err.message || "更新失敗"); - } - }; - - // 批次刪除 - const handleBatchDelete = async () => { - if (selectedRowKeys.length === 0) return; - try { - for (let commentId of selectedRowKeys) { - await deleteCommentApi(commentId); - } - message.success("批次刪除成功"); - setComments((prev) => prev.filter((c) => !selectedRowKeys.includes(c.id))); - setSelectedRowKeys([]); - } catch (err) { - message.error("批次刪除失敗:" + err.message); - } - }; - - // Table columns - const columns = [ - { title: "ID", dataIndex: "id", width: 80 }, - { - title: "留言內容", - dataIndex: "content", - sorter: (a, b) => a.content.localeCompare(b.content), - render: (text) => {text}, - }, - { - title: "建立日期", - dataIndex: "createdAt", - width: 180, - sorter: (a, b) => - new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), - }, - { - title: "操作", - width: 120, - render: (_, record) => ( - <> - - - - ), - }, - ]; - - // 多選設定 - const rowSelection = { - selectedRowKeys, - onChange: (keys) => setSelectedRowKeys(keys), - }; - - // 前端篩選:依「關鍵字 + 日期區間」 - const filteredComments = comments.filter((item) => { - // 1) 關鍵字 - const matchSearch = item.content - .toLowerCase() - .includes(searchText.toLowerCase()); - - // 2) 日期 - let matchDate = true; - if (dateRange?.[0] && dateRange?.[1]) { - const start = dateRange[0].startOf("day"); - const end = dateRange[1].endOf("day"); - const created = moment(item.createdAt, "YYYY-MM-DD HH:mm"); - matchDate = created.isBetween(start, end, null, "[]"); - } - return matchSearch && matchDate; - }); - - return ( - -

我的留言

- - {/* 若正在 loading,顯示 Spin */} - {loading ? ( - - ) : ( - - {/* 搜尋、日期篩選、批次刪除、重新整理 */} -
- setSearchText(e.target.value)} - /> - - - - setDateRange(dates || [])} - /> - - - - - - )} - - {/* 主表格 */} -
- - {/* 編輯留言 Modal */} - setEditModalVisible(false)} - onSave={handleSaveComment} - /> - - ); -} - -/** 編輯留言彈窗 */ -function EditCommentModal({ visible, comment, onCancel, onSave }) { - const [form] = Form.useForm(); - - useEffect(() => { - if (comment) { - form.setFieldsValue(comment); - } else { - form.resetFields(); - } - }, [comment, form]); - - const handleFinish = (values) => { - onSave(values); - }; - - return ( - form.submit()} - okText="儲存" - cancelText="取消" - destroyOnClose - > -
- - - - - -
- ); -} diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index 41428882fd1..2c54bfc0f66 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -1,6 +1,5 @@ // src/pages/dashboard.js import { - CommentOutlined, DatabaseOutlined, HomeOutlined, KeyOutlined, @@ -29,7 +28,6 @@ import { useAuth } from "../context/AuthContext"; // Dashboard 子頁面 import DashboardApiKey from "../components/Dashboard/ApiKey"; import DashboardApiUsage from "../components/Dashboard/ApiUsage"; -import DashboardMyComments from "../components/Dashboard/MyComments"; import DashboardMyInfo from "../components/Dashboard/MyInfo"; const { Header: AntHeader, Sider, Content, Footer } = AntLayout; @@ -44,7 +42,6 @@ const localeText = { expanded: "我的後台", menu: { myinfo: "我的資訊", - comments: "我的留言", apikey: "我的 API Key", apiusage: "API 使用紀錄", }, @@ -52,7 +49,6 @@ const localeText = { breadcrumb: { dashboard: "我的後台", myinfo: "我的資訊", - comments: "我的留言", apikey: "我的 API Key", apiusage: "API 使用紀錄", undefined: "未定義", @@ -74,7 +70,6 @@ const localeText = { expanded: "My Dashboard", menu: { myinfo: "My Information", - comments: "My Comments", apikey: "My API Key", apiusage: "API Usage", }, @@ -82,7 +77,6 @@ const localeText = { breadcrumb: { dashboard: "Dashboard", myinfo: "My Information", - comments: "My Comments", apikey: "My API Key", apiusage: "API Usage", undefined: "Undefined", @@ -104,7 +98,6 @@ const localeText = { expanded: "マイダッシュボード", menu: { myinfo: "マイ情報", - comments: "マイコメント", apikey: "マイAPIキー", apiusage: "API利用状況", }, @@ -112,7 +105,6 @@ const localeText = { breadcrumb: { dashboard: "ダッシュボード", myinfo: "マイ情報", - comments: "マイコメント", apikey: "マイAPIキー", apiusage: "API利用状況", undefined: "未定義", @@ -147,8 +139,6 @@ export default function DashboardPage() { switch (selectedKey) { case "myinfo": return ; - case "comments": - return ; case "apikey": return ; case "apiusage": @@ -162,8 +152,6 @@ export default function DashboardPage() { switch (selectedKey) { case "myinfo": return text.breadcrumb.myinfo; - case "comments": - return text.breadcrumb.comments; case "apikey": return text.breadcrumb.apikey; case "apiusage": @@ -247,7 +235,6 @@ export default function DashboardPage() { onClick={(e) => setSelectedKey(e.key)} items={[ { key: "myinfo", icon: , label: text.sider.menu.myinfo }, - { key: "comments", icon: , label: text.sider.menu.comments }, { key: "apikey", icon: , label: text.sider.menu.apikey }, { key: "apiusage", icon: , label: text.sider.menu.apiusage }, ]} From 42d42860574b75f5c26861327ba3367ab0135b81 Mon Sep 17 00:00:00 2001 From: zephyr-sh Date: Thu, 13 Mar 2025 08:14:15 +0800 Subject: [PATCH 12/25] [A] Add api key pages --- src/components/Dashboard/ApiKey/index.js | 741 ++++++++++-------- .../Dashboard/ApiKey/index.module.css | 77 ++ src/components/Dashboard/ApiUsage/index.js | 125 ++- 3 files changed, 588 insertions(+), 355 deletions(-) create mode 100644 src/components/Dashboard/ApiKey/index.module.css diff --git a/src/components/Dashboard/ApiKey/index.js b/src/components/Dashboard/ApiKey/index.js index 92e014c5dc2..907bd03f1ab 100644 --- a/src/components/Dashboard/ApiKey/index.js +++ b/src/components/Dashboard/ApiKey/index.js @@ -1,395 +1,486 @@ -// 檔案: src/components/dashboard/DashboardApiKey/index.js +// src/components/Dashboard/ApiKey/index.js -import { EyeInvisibleOutlined, EyeOutlined } from "@ant-design/icons"; +import { + CopyOutlined, + DeleteOutlined, + ExclamationCircleOutlined, + EyeInvisibleOutlined, + EyeOutlined, + InfoCircleOutlined, + PlusOutlined, +} from "@ant-design/icons"; import { Button, Card, - Col, + Checkbox, + Collapse, Drawer, Form, Input, + InputNumber, + List, message, Modal, Popconfirm, - Progress, - Row, + Select, Space, - Switch, - Table, - Tag, + Tabs, + Tooltip, } from "antd"; -import moment from "moment"; -import React, { useEffect, useState } from "react"; -import { - createApiKeyApi, - deleteApiKeyApi, - getApiKeyUsageApi, - getMyApiKeysApi, - regenerateApiKeyApi, - updateApiKeyNameApi, -} from "../../../utils/mockApi"; +import React, { useCallback, useEffect, useState } from "react"; +import { useAuth } from "../../../context/AuthContext"; +import styles from "./index.module.css"; + +// API 基底路徑 +const API_BASE_URL = "https://api.docsaid.org/public/token"; + +// 依據 usage_plan_id 回傳方案名稱 +const getPlanName = (id) => { + switch (id) { + case 1: + return "Basic (Free)"; + case 2: + return "Pro (Paid)"; + case 3: + return "PayAsYouGo"; + default: + return "Unknown"; + } +}; + +// Token 列表中的單一項目元件 +const TokenCard = ({ item, copyToken, handleRevoke, openDrawer, maskToken }) => { + const plan = getPlanName(item.usage_plan_id); + return ( + + + + {item.name || "Untitled Key"} + + {plan} + + } + extra={ + item.is_active && ( + } + onConfirm={() => handleRevoke(item)} + > + + + ) + } + > + +
+ 到期時間: + {item.expires_at || "永久"} +
+
+ 狀態: + {item.is_active ? "Active" : "Revoked"} +
+ + {item.is_active && ( +
+ +
+ )} +
+
+ ); +}; export default function DashboardApiKey() { + const { token } = useAuth(); // 使用者登入 Token const [loading, setLoading] = useState(false); const [apiKeys, setApiKeys] = useState([]); - const [createModalVisible, setCreateModalVisible] = useState(false); - // Drawer 狀態:顯示單一 API Key 詳細資訊 - const [detailDrawerVisible, setDetailDrawerVisible] = useState(false); - const [detailLoading, setDetailLoading] = useState(false); - const [detailData, setDetailData] = useState(null); + // 新增 Token 的 Modal + const [createModalVisible, setCreateModalVisible] = useState(false); + const [createForm] = Form.useForm(); - // 「顯示 / 隱藏」 Key 內容 - const [showKeys, setShowKeys] = useState({}); - // 例如 { keyId: true/false } + // Drawer 用於查看用量、詳細 + const [drawerVisible, setDrawerVisible] = useState(false); + const [drawerToken, setDrawerToken] = useState(null); // 目前開啟 Drawer 所顯示的 Token - // 建立新的 Key 表單 - const [form] = Form.useForm(); + // 是否顯示「明碼」(馬賽克開關) + const [showTokenPlain, setShowTokenPlain] = useState(false); - useEffect(() => { - fetchApiKeys(); - }, []); - - const fetchApiKeys = async () => { + // =============================== + // 1) 載入 Token 列表 + // =============================== + const fetchTokens = useCallback(async () => { + if (!token) return; setLoading(true); try { - const data = await getMyApiKeysApi(); + const res = await fetch(`${API_BASE_URL}/my-tokens`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + if (!res.ok) { + throw new Error(`Fetch tokens failed: ${res.status}`); + } + const data = await res.json(); + // data: [{ jti, usage_plan_id, expires_at, is_active }, ...] setApiKeys(data); } catch (err) { - message.error(err.message || "取得 API Key 失敗"); + message.error(err.message); } finally { setLoading(false); } - }; + }, [token]); - // Modal:建立新的 API Key - const handleCreateKey = async (values) => { - try { - const newKeyObj = await createApiKeyApi(values.name); - message.success("已成功建立新 API Key"); - setApiKeys((prev) => [...prev, newKeyObj]); - setCreateModalVisible(false); - form.resetFields(); - } catch (err) { - message.error(err.message || "建立失敗"); - } - }; + useEffect(() => { + fetchTokens(); + }, [fetchTokens]); - // 「刪除」某把 Key - const handleDeleteKey = async (record) => { - try { - await deleteApiKeyApi(record.id); - message.success(`已刪除 API Key: ${record.name}`); - setApiKeys((prev) => prev.filter((k) => k.id !== record.id)); - } catch (err) { - message.error(err.message || "刪除失敗"); - } - }; + // =============================== + // 2) 新增 Token + // =============================== + const handleOpenCreateModal = useCallback(() => { + createForm.resetFields(); + setCreateModalVisible(true); + }, [createForm]); - // 「重新生成」某把 Key - const handleRegenerateKey = async (record) => { - try { - const newKeyStr = await regenerateApiKeyApi(record.id); - message.success("已重新生成 API Key"); - // 更新該 Key 的 keyString - setApiKeys((prev) => - prev.map((item) => - item.id === record.id ? { ...item, keyString: newKeyStr } : item - ) - ); - } catch (err) { - message.error(err.message || "重新生成失敗"); - } - }; + const handleCreateToken = async (values) => { + // 從表單取 usage_plan_id, isPermanent, expires_minutes, name + const { usage_plan_id, isPermanent, expires_minutes, name } = values; + // 若勾選永久 => expires_minutes = 999999 + const finalExpires = isPermanent ? 999999 : expires_minutes; - // 「重新命名」Key - const handleRenameKey = async (record, newName) => { + setLoading(true); try { - await updateApiKeyNameApi(record.id, newName); - message.success("名稱已更新"); - setApiKeys((prev) => - prev.map((item) => - item.id === record.id ? { ...item, name: newName } : item - ) + const res = await fetch( + `${API_BASE_URL}/?usage_plan_id=${usage_plan_id}&expires_minutes=${finalExpires}`, + { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + } ); - } catch (err) { - message.error(err.message || "更新失敗"); - } - }; + if (!res.ok) { + const e = await res.json().catch(() => ({})); + throw new Error(e.detail || `Create token failed ${res.status}`); + } + const data = await res.json(); + // data: { access_token, expires_at, usage_plan_id, ... } - // 打開 Drawer,顯示更詳細資訊 (例如:用量, IP白名單, 失效日期, etc.) - const openDetailDrawer = async (record) => { - setDetailDrawerVisible(true); - setDetailLoading(true); - try { - // 可能要呼叫後端取得更完整資訊 (例如 getApiKeyUsageApi) - const usageData = await getApiKeyUsageApi(record.id); - setDetailData({ - ...record, - usage: usageData.usage, - limit: usageData.limit, - whitelist: usageData.ipWhitelist || [], + // 用 Modal.success 提示一次性顯示完整 Token + Modal.success({ + title: "Token 已建立!", + content: ( +
+

請妥善保存以下 Token,將不會再次顯示:

+
{data.access_token}
+
+ ), }); + + // 新建列表中的項目 + setApiKeys((prev) => [ + { + jti: data.access_token, + usage_plan_id: data.usage_plan_id, + expires_at: data.expires_at, + is_active: true, + name: name || "", // 目前後端可能沒存 name + }, + ...prev, + ]); + + setCreateModalVisible(false); } catch (err) { - message.error("取得詳細資料失敗:" + err.message); + message.error(err.message); } finally { - setDetailLoading(false); + setLoading(false); } }; - // 顯示/隱藏某把 Key - const toggleShowKey = (record) => { - setShowKeys((prev) => ({ - ...prev, - [record.id]: !prev[record.id], - })); - }; - - // Table欄位定義 - const columns = [ - { - title: "名稱", - dataIndex: "name", - render: (text, record) => ( - handleRenameKey(record, newVal)} - /> - ), + // =============================== + // 3) 撤銷 Token + // =============================== + const handleRevoke = useCallback( + async (item) => { + setLoading(true); + try { + const res = await fetch(`${API_BASE_URL}/revoke`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ jti: item.jti }), + }); + if (!res.ok) { + const e = await res.json().catch(() => ({})); + throw new Error(e.detail || `Revoke token failed: ${res.status}`); + } + message.success("Token 已撤銷"); + // 從列表移除 + setApiKeys((prev) => prev.filter((key) => key.jti !== item.jti)); + // 若 Drawer 正在看該 Token => 關閉 + if (drawerToken && drawerToken.jti === item.jti) { + setDrawerVisible(false); + setDrawerToken(null); + } + } catch (err) { + message.error(err.message); + } finally { + setLoading(false); + } }, - { - title: "API Key", - dataIndex: "keyString", - render: (text, record) => { - // 顯示 / 隱藏 Key - const showing = showKeys[record.id]; - // 若不顯示則遮蔽中間段 - const masked = text - ? showing - ? text - : maskKeyString(text) - : "N/A"; - return ( - - {masked} - + } else { + message.info(`已用:${usageData.used_this_hour}(Pay-Per-Use, 無限)`); + } + } catch (err) { + message.error(err.message); + } + }; - + // =============================== + // 6) Token 顯示(馬賽克 + 複製) + // =============================== + const copyToken = useCallback(async (val) => { + try { + await navigator.clipboard.writeText(val); + message.success("已複製 Token"); + } catch { + message.error("複製失敗"); + } + }, []); - handleDeleteKey(record)} - > - - - - ), + const maskToken = useCallback( + (val) => { + if (!val) return ""; + if (showTokenPlain) return val; // 顯示全文 + // 預設只顯示前6 & 後4 + const front = val.slice(0, 6); + const back = val.slice(-4); + return front + "****" + back; }, - ]; + [showTokenPlain] + ); + // =============================== + // Render + // =============================== return ( - - -
- - - - - - +
+
+

My API Keys

+

在此管理、檢視、撤銷你的公開 Token

+
-
+
+ + +
- {/* 建立新 API Key 的 Modal */} { - setCreateModalVisible(false); - form.resetFields(); - }} - onOk={() => form.submit()} - okText="建立" - cancelText="取消" + onCancel={() => setCreateModalVisible(false)} + footer={null} destroyOnClose >
+ + + + + + + 若勾選,忽略「有效期」 + + - + + + + + + + +
- {/* 詳細資訊 Drawer */} +
+ + + + 我的 Token 列表 +
+ } + > + item.jti} + renderItem={(item) => ( + + )} + /> + + + + setDetailDrawerVisible(false)} + title={ + drawerToken + ? `${drawerToken.name || "My Token"} - 詳細` + : "Token Info" + } + open={drawerVisible} + onClose={closeDrawer} + width={420} > - {detailLoading ? ( -

載入中...

- ) : detailData ? ( - - ) : ( -

無法載入資料

+ {drawerToken && ( + <> +

+ Token (遮罩): + + copyToken(drawerToken.jti)}> + {maskToken(drawerToken.jti)} + + + +

+

+ 到期時間:{" "} + {drawerToken.expires_at || "永久"} +

+

+ 狀態:{" "} + {drawerToken.is_active ? "Active" : "Revoked"} +

+ + +

+ 這裡可以顯示用量統計,或按下「查詢用量」按鈕。 +

+ +
+ +

可以放一些額外的說明 / log / 版本紀錄…

+
+
+ )}
- - ); -} - -/** - * Drawer 內容 - * 這裡可以顯示更深入資訊,如「用量統計」、「IP 白名單」、「是否啟用 / 停用」等。 - */ -function DetailContent({ detailData }) { - const { usage = 0, limit = 1000, whitelist = [] } = detailData; - - const usagePercent = Math.min(100, Math.round((usage / limit) * 100)); - - return ( -
-

顯示更多資訊,例如 IP 白名單、用量概覽等。

- -
目前用量: - - -
- {usage} / {limit} 次呼叫 -
- - - - -
IP 白名單: - - {whitelist.length === 0 ? ( - 未設定 - ) : ( - whitelist.map((ip) => {ip}) - )} - - - - - 啟用狀態: - - ...} // 可在此呼叫後端 API 更新狀態 - /> - - ); } - -/** - * 編輯 API Key 名稱的小組件:點擊文字 -> 變成可輸入 - */ -function EditableText({ text, onSave }) { - const [editing, setEditing] = useState(false); - const [val, setVal] = useState(text); - - const handleSubmit = () => { - if (val.trim() && val !== text) { - onSave(val.trim()); - } - setEditing(false); - }; - - if (editing) { - return ( - setVal(e.target.value)} - onBlur={handleSubmit} - onPressEnter={handleSubmit} - style={{ width: 150 }} - /> - ); - } - return setEditing(true)} style={{ cursor: "pointer" }}>{text}; -} - -/** - * 將字串中段以 `*` 隱藏,例如: ABCDEFG123 => AB****3123 - */ -function maskKeyString(keyString = "") { - if (keyString.length < 8) { - return "****"; - } - const prefix = keyString.slice(0, 2); - const suffix = keyString.slice(-4); - return prefix + "****" + suffix; -} diff --git a/src/components/Dashboard/ApiKey/index.module.css b/src/components/Dashboard/ApiKey/index.module.css new file mode 100644 index 00000000000..0eb7c0aedaf --- /dev/null +++ b/src/components/Dashboard/ApiKey/index.module.css @@ -0,0 +1,77 @@ +.apiKeyContainer { + max-width: 900px; + margin: 0 auto; + padding: 24px; + text-align: left; /* 左上角對齊 */ + } + + .header { + margin-bottom: 16px; + text-align: left; /* 左上角對齊 */ + } + .header h2 { + font-size: 1.6rem; + margin: 0 0 4px 0; + } + .header p { + color: #777; + margin: 0; + } + + .actions { + margin-bottom: 16px; + text-align: left; /* 左對齊,也可 right */ + display: flex; + gap: 8px; + } + + .tokenBox { + background: #f5f5f5; + padding: 8px; + word-break: break-all; + font-family: monospace; + border: 1px solid #ccc; + border-radius: 4px; + } + + .collapseRoot { + background-color: #fff; + border: 1px solid #e8e8e8; + border-radius: 8px; + } + + .tokenListItem { + border: none !important; + margin-bottom: 16px; + } + + .tokenCard { + width: 100%; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + } + + .tokenTitle { + display: flex; + align-items: center; + justify-content: space-between; + } + + .tokenName { + font-weight: 600; + font-size: 1rem; + } + .tokenPlan { + font-size: 0.9rem; + color: #888; + } + + .tokenItemRow { + margin: 4px 0; + line-height: 1.6; + } + .label { + font-weight: 500; + color: #555; + margin-right: 4px; + } diff --git a/src/components/Dashboard/ApiUsage/index.js b/src/components/Dashboard/ApiUsage/index.js index 545302f68ec..bac5c97c3bc 100644 --- a/src/components/Dashboard/ApiUsage/index.js +++ b/src/components/Dashboard/ApiUsage/index.js @@ -1,48 +1,113 @@ -// src/components/dashboard/DashboardApiUsage.jsx -import { message, Spin, Table } from "antd"; -import React, { useEffect, useState } from "react"; -import { getApiUsageApi } from "../../../utils/mockApi"; +import { Button, Card, Descriptions, Input, message, Space, Tabs } from "antd"; +import React, { useState } from "react"; +import { useAuth } from "../../../context/AuthContext"; +/** + * 假設我們只要展示某一支「公開 Token」的當前用量。 + * 也可以列出多筆 Token,讓使用者選擇要查看哪一個 Token。 + * + * 並在畫面下方顯示「DocAligner」的使用範例程式碼 (cURL / Python). + */ export default function DashboardApiUsage() { + const { token } = useAuth(); // 一般登入的 token (非公開 token) + const [publicToken, setPublicToken] = useState(""); + const [usageData, setUsageData] = useState(null); const [loading, setLoading] = useState(false); - const [usageList, setUsageList] = useState([]); - useEffect(() => { - fetchUsage(); - }, []); - - const fetchUsage = async () => { + // =========== 查詢使用量 =========== + const handleCheckUsage = async () => { + if (!publicToken) { + message.warning("請先輸入公開 Token"); + return; + } setLoading(true); try { - const data = await getApiUsageApi(); - setUsageList(data); + const res = await fetch("https://api.docsaid.org/public/token/usage", { + headers: { + Authorization: `Bearer ${publicToken}`, + }, + }); + if (!res.ok) { + const e = await res.json().catch(() => ({})); + throw new Error(e.detail || "Failed to get usage"); + } + const data = await res.json(); + setUsageData(data); + message.success("Usage updated!"); } catch (err) { - message.error(err.message || "取得 API 使用紀錄失敗"); + message.error(err.message); + setUsageData(null); } finally { setLoading(false); } }; - const columns = [ - { title: "呼叫時間", dataIndex: "timestamp", width: 200 }, - { title: "API 路徑", dataIndex: "endpoint" }, - { title: "狀態碼", dataIndex: "statusCode", width: 120 }, - { title: "耗時 (ms)", dataIndex: "latency", width: 120 }, - ]; + // 範例:顯示 cURL / Python 的程式碼,使用 docAligner-public + // 你可用 Tabs 切換不同語言 + const docalignerCodeCurl = `curl -X POST https://api.docsaid.org/docaligner-public-predict \\ + -H "Authorization: Bearer ${publicToken || ""}" \\ + -F "file=@/path/to/your/document.jpg"`; + + const docalignerCodePython = `import requests + +url = "https://api.docsaid.org/docaligner-public-predict" +headers = { + "Authorization": "Bearer ${publicToken || ""}" +} +files = { + "file": open("/path/to/your/document.jpg", "rb") +} +response = requests.post(url, headers=headers, files=files) +print(response.json())`; return ( -
-

API 使用紀錄

- {loading ? ( - - ) : ( -
+ + setPublicToken(e.target.value)} + style={{ width: 400 }} /> + + + + {usageData && ( + + {usageData.billing_type} + {usageData.used_this_hour ?? "-"} + {usageData.remaining !== undefined && ( + {usageData.remaining} + )} + )} - + + + +{docalignerCodeCurl} + + ), + }, + { + key: "python", + label: "Python", + children: ( +
+{docalignerCodePython}
+                
+ ), + }, + ]} + /> +
+ ); } From b8a96102ba0b0744994d665201ea497d29de5f4c Mon Sep 17 00:00:00 2001 From: zephyr-sh Date: Thu, 13 Mar 2025 16:59:42 +0800 Subject: [PATCH 13/25] [C] Update i18n settings --- src/components/Dashboard/MyInfo/index.js | 268 ++++----------------- src/components/Dashboard/MyInfo/locales.js | 192 +++++++++++++++ 2 files changed, 243 insertions(+), 217 deletions(-) create mode 100644 src/components/Dashboard/MyInfo/locales.js diff --git a/src/components/Dashboard/MyInfo/index.js b/src/components/Dashboard/MyInfo/index.js index 1b93e4e1275..914f284f1ae 100644 --- a/src/components/Dashboard/MyInfo/index.js +++ b/src/components/Dashboard/MyInfo/index.js @@ -1,4 +1,3 @@ -// /src/components/Dashboard/MyInfo/index.js import { UploadOutlined, UserOutlined } from "@ant-design/icons"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { @@ -21,132 +20,15 @@ import moment from "moment"; import React, { useCallback, useEffect, useState } from "react"; import PasswordInput from "../../../components/PasswordInput"; import { useAuth } from "../../../context/AuthContext"; +import { changePasswordLocale, dashboardLocale, deleteAccountLocale } from "./locales"; const { Text } = Typography; -const localeText = { - "zh-hant": { - myInfoTitle: "我的資訊", - emailNotVerifiedAlertTitle: "您的 Email 尚未驗證", - emailNotVerifiedAlertDesc: "沒有驗證信箱,帳號密碼丟失後無法找回。請點選下方寄送驗證信,並檢查您的信箱。", - noEmailAlertTitle: "尚未填寫 Email", - noEmailAlertDesc: "您尚未綁定 Email,請點 {editLink} 補上 Email。", - uploadAvatarButtonUploading: "上傳中...", - uploadAvatarButton: "上傳頭像", - accountLabel: "帳號", - emailLabel: "Email", - phoneLabel: "電話", - birthLabel: "生日", - notSet: "(未設定)", - lastLoginTimeLabel: "上次登入時間:", - lastLoginIpLabel: "上次登入 IP:", - statusLabel: "狀態", - editButton: "編輯", - changePasswordButton: "變更密碼", - deleteAccountButton: "刪除帳號", - successMsg: "個人資料更新成功", - fetchUserInfoFailure: "取得使用者資訊失敗", - avatarUploadFailure: "頭像上傳失敗", - avatarUploadSuccess: "頭像已更新", - verified: "已驗證", - notVerified: "未驗證", - resendVerification: "寄送驗證信", - changePasswordSuccess: "密碼變更成功!", - passwordMismatch: "兩次輸入的密碼不一致", - changePasswordFailureTitle: "變更密碼失敗", - verificationModalTitle: "寄送驗證信", - verificationModalOk: "確定", - verificationModalCancel: "取消", - verificationModalExampleEmail: "預設信箱 (example.com) 無法用於驗證,請更換為有效的 Email。", - verificationModalDesc: "系統將發送驗證信至:{email}\n請確認此 Email 是否正確?", - deleteAccountSuccess: "帳號已刪除,將導回首頁", - saveButton: "儲存", - cancelButton: "取消", - changePasswordModalOk: "確定", - }, - en: { - myInfoTitle: "My Information", - emailNotVerifiedAlertTitle: "Your Email is not verified", - emailNotVerifiedAlertDesc: "Please click the button below to resend the verification email and check your inbox.", - noEmailAlertTitle: "Email not set", - noEmailAlertDesc: "You have not bound an Email. Please click {editLink} to add an Email.", - uploadAvatarButtonUploading: "Uploading...", - uploadAvatarButton: "Upload Avatar", - accountLabel: "Account", - emailLabel: "Email", - phoneLabel: "Phone", - birthLabel: "Birth", - notSet: "(Not set)", - lastLoginTimeLabel: "Last Login Time: ", - lastLoginIpLabel: "Last Login IP: ", - statusLabel: "Status", - editButton: "Edit", - changePasswordButton: "Change Password", - deleteAccountButton: "Delete Account", - successMsg: "Profile updated successfully", - fetchUserInfoFailure: "Failed to fetch user information", - avatarUploadFailure: "Avatar upload failed", - avatarUploadSuccess: "Avatar updated", - verified: "Verified", - notVerified: "Not verified", - resendVerification: "Resend Verification", - changePasswordSuccess: "Password changed successfully!", - passwordMismatch: "Passwords do not match", - changePasswordFailureTitle: "Change Password Failed", - verificationModalTitle: "Resend Verification Email", - verificationModalOk: "Confirm", - verificationModalCancel: "Cancel", - verificationModalExampleEmail: "Default email (example.com) cannot be used for verification. Please change to a valid Email.", - verificationModalDesc: "The system will send a verification email to: {email}\nPlease confirm if this Email is correct.", - deleteAccountSuccess: "Account deleted, redirecting to homepage", - saveButton: "Save", - cancelButton: "Cancel", - changePasswordModalOk: "Confirm", - }, - ja: { - myInfoTitle: "私の情報", - emailNotVerifiedAlertTitle: "メールが未認証です", - emailNotVerifiedAlertDesc: "下のボタンをクリックして認証メールを再送信し、受信箱を確認してください。", - noEmailAlertTitle: "メール未設定", - noEmailAlertDesc: "メールが登録されていません。{editLink} をクリックしてメールを追加してください。", - uploadAvatarButtonUploading: "アップロード中...", - uploadAvatarButton: "アバターをアップロード", - accountLabel: "アカウント", - emailLabel: "メール", - phoneLabel: "電話", - birthLabel: "生年月日", - notSet: "(未設定)", - lastLoginTimeLabel: "最終ログイン時間:", - lastLoginIpLabel: "最終ログイン IP:", - statusLabel: "状態", - editButton: "編集", - changePasswordButton: "パスワード変更", - deleteAccountButton: "アカウント削除", - successMsg: "プロフィール更新成功", - fetchUserInfoFailure: "ユーザー情報の取得に失敗しました", - avatarUploadFailure: "アバターのアップロードに失敗しました", - avatarUploadSuccess: "アバターが更新されました", - verified: "認証済み", - notVerified: "未認証", - resendVerification: "認証メール再送信", - changePasswordSuccess: "パスワード変更成功!", - passwordMismatch: "入力したパスワードが一致しません", - changePasswordFailureTitle: "パスワード変更失敗", - verificationModalTitle: "認証メール再送信", - verificationModalOk: "確定", - verificationModalCancel: "キャンセル", - verificationModalExampleEmail: "デフォルトのメール (example.com) は認証に使用できません。有効なメールに変更してください。", - verificationModalDesc: "システムは以下のメールに認証メールを送信します:{email}\nこのメールが正しいか確認してください。", - deleteAccountSuccess: "アカウントが削除されました。ホームページにリダイレクトします。", - saveButton: "保存", - cancelButton: "キャンセル", - changePasswordModalOk: "確定", - }, -}; - export default function DashboardMyInfo() { - const { i18n: { currentLocale } } = useDocusaurusContext(); - const text = localeText[currentLocale] || localeText.en; + const { + i18n: { currentLocale }, + } = useDocusaurusContext(); + const text = dashboardLocale[currentLocale] || dashboardLocale.en; const { token, @@ -396,32 +278,36 @@ export default function DashboardMyInfo() { )} - -
- } - onError={() => false} - /> -
- - - -
- + +
+ } + onError={() => false} + /> +
+ + + +
+ {editing ? ( @@ -447,8 +333,14 @@ export default function DashboardMyInfo() { label={text.emailLabel} name="email" rules={[ - { required: true, message: text.emailRequired || "請輸入 Email" }, - { type: "email", message: text.invalidEmail || "Email 格式錯誤" }, + { + required: true, + message: text.emailRequired || "請輸入 Email", + }, + { + type: "email", + message: text.invalidEmail || "Email 格式錯誤", + }, ]} > {user?.is_email_verified === false ? : } @@ -591,49 +483,10 @@ export default function DashboardMyInfo() { } function ChangePasswordModal({ visible, onCancel, onSubmit, form }) { - const { i18n: { currentLocale } } = useDocusaurusContext(); - const localeText = { - "zh-hant": { - modalTitle: "變更密碼", - oldPasswordLabel: "舊密碼", - oldPasswordRequired: "請輸入舊密碼", - newPasswordLabel: "新密碼", - newPasswordRequired: "請輸入新密碼", - newPasswordTooShort: "至少 8 碼", - confirmNewPasswordLabel: "確認新密碼", - confirmNewPasswordRequired: "請再次輸入新密碼", - passwordMismatch: "兩次輸入的密碼不一致", - okText: "儲存", - cancelText: "取消", - }, - en: { - modalTitle: "Change Password", - oldPasswordLabel: "Old Password", - oldPasswordRequired: "Please enter your old password", - newPasswordLabel: "New Password", - newPasswordRequired: "Please enter your new password", - newPasswordTooShort: "At least 8 characters", - confirmNewPasswordLabel: "Confirm New Password", - confirmNewPasswordRequired: "Please re-enter your new password", - passwordMismatch: "Passwords do not match", - okText: "Save", - cancelText: "Cancel", - }, - ja: { - modalTitle: "パスワード変更", - oldPasswordLabel: "旧パスワード", - oldPasswordRequired: "旧パスワードを入力してください", - newPasswordLabel: "新パスワード", - newPasswordRequired: "新パスワードを入力してください", - newPasswordTooShort: "8文字以上である必要があります", - confirmNewPasswordLabel: "新パスワード確認", - confirmNewPasswordRequired: "新パスワードを再入力してください", - passwordMismatch: "入力したパスワードが一致しません", - okText: "保存", - cancelText: "キャンセル", - }, - }; - const text = localeText[currentLocale] || localeText.en; + const { + i18n: { currentLocale }, + } = useDocusaurusContext(); + const text = changePasswordLocale[currentLocale] || changePasswordLocale.en; const [passwordStrength, setPasswordStrength] = useState(""); @@ -727,29 +580,10 @@ function ChangePasswordModal({ visible, onCancel, onSubmit, form }) { } function DeleteAccountModal({ visible, onCancel, onDelete }) { - const { i18n: { currentLocale } } = useDocusaurusContext(); - const localeText = { - "zh-hant": { - modalTitle: "刪除帳號", - modalContent: "您確定要刪除帳號嗎?此操作無法復原!", - okText: "確定刪除", - cancelText: "取消", - }, - en: { - modalTitle: "Delete Account", - modalContent: - "Are you sure you want to delete your account? This action cannot be undone!", - okText: "Confirm Delete", - cancelText: "Cancel", - }, - ja: { - modalTitle: "アカウント削除", - modalContent: "本当にアカウントを削除しますか?この操作は元に戻せません!", - okText: "削除を確定", - cancelText: "キャンセル", - }, - }; - const text = localeText[currentLocale] || localeText.en; + const { + i18n: { currentLocale }, + } = useDocusaurusContext(); + const text = deleteAccountLocale[currentLocale] || deleteAccountLocale.en; return ( Date: Thu, 13 Mar 2025 17:04:21 +0800 Subject: [PATCH 14/25] [C] Update dashboard-myinfo --- src/components/AuthModal.js | 33 +++++++--------- src/components/Dashboard/MyInfo/index.js | 26 ++++++++----- src/context/AuthContext.js | 49 +++++++++++++----------- src/hooks/useAuthHandler.js | 32 ++++++---------- 4 files changed, 67 insertions(+), 73 deletions(-) diff --git a/src/components/AuthModal.js b/src/components/AuthModal.js index 1acf21734fa..1174f5aeb0f 100644 --- a/src/components/AuthModal.js +++ b/src/components/AuthModal.js @@ -1,7 +1,7 @@ // /src/components/AuthModal.js import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { Divider, Modal, Tabs, Typography } from "antd"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import useAuthHandler from "../hooks/useAuthHandler"; import ForgotPasswordForm from "./forms/ForgotPasswordForm"; import LoginForm from "./forms/LoginForm"; @@ -39,6 +39,14 @@ export default function AuthModal({ visible, onCancel }) { const lang = currentLocale; const texts = i18nTexts[lang] || i18nTexts.en; // 預設英語 const [mode, setMode] = useState("login"); + + // 當 modal 關閉時,自動重置模式為 "login" + useEffect(() => { + if (!visible) { + setMode("login"); + } + }, [visible]); + const goToForgotPassword = () => setMode("forgotPassword"); const goToLogin = () => setMode("login"); const goToRegister = () => setMode("register"); @@ -74,29 +82,16 @@ export default function AuthModal({ visible, onCancel }) { ); - const renderContent = () => { - switch (mode) { - case "login": - return renderLoginContent(); - case "register": - return renderRegisterContent(); - case "forgotPassword": - return renderForgotPasswordContent(); - default: - return null; - } - }; - return ( - {/* 如果當前是忘記密碼模式,就脫離 Tabs,獨立顯示 */} + {/* 若當前是忘記密碼模式,就不使用 Tabs */} {mode === "forgotPassword" ? ( - renderContent() + renderForgotPasswordContent() ) : ( ; } + // 修正 Alert 中替換字串的問題 + const renderNoEmailAlertDescription = () => { + // 假設 text.noEmailAlertDesc 為 "請設定您的 Email,點擊 {editLink} 進行設定" + const parts = text.noEmailAlertDesc.split("{editLink}"); + return ( + <> + {parts[0]} + + {parts[1]} + + ); + }; + const renderEmailStatus = () => { if (!user?.email) { return {text.notSet}; @@ -262,16 +277,7 @@ export default function DashboardMyInfo() { - {text.noEmailAlertDesc.replace( - "{editLink}", - - )} - - } + description={renderNoEmailAlertDescription()} type="info" showIcon /> diff --git a/src/context/AuthContext.js b/src/context/AuthContext.js index a6ee5ec7214..8214405f929 100644 --- a/src/context/AuthContext.js +++ b/src/context/AuthContext.js @@ -25,12 +25,12 @@ export function AuthProvider({ children }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); - // 2. 取得 Docusaurus 的 currentLocale + // 取得 Docusaurus 的 currentLocale const { i18n: { currentLocale }, } = useDocusaurusContext(); - // 3. 根據 currentLocale 對照,取得後端需要的語言代碼 + // 根據 currentLocale,取得後端所需語言代碼 const serverLang = currentLocale; /** @@ -42,25 +42,30 @@ export function AuthProvider({ children }) { */ async function apiRequest(endpoint, method = "GET", tokenArg = null, body = null) { const headers = {}; - // 加入 Bearer Token if (tokenArg) { headers.Authorization = `Bearer ${tokenArg}`; } - // 加入語系 headers["Accept-Language"] = serverLang; - // 判斷是否為 FormData const isFormData = body instanceof FormData; if (body && !isFormData) { headers["Content-Type"] = "application/json"; body = JSON.stringify(body); } - const res = await fetch(`${API_BASE}${endpoint}`, { - method, - headers, - body: body || undefined, - }); + let res; + try { + res = await fetch(`${API_BASE}${endpoint}`, { + method, + headers, + body: body || undefined, + // 可根據需求明確設定 redirect 選項,預設為 follow + redirect: "follow", + }); + } catch (networkError) { + console.error(`Network error during ${method} ${endpoint}:`, networkError); + throw new Error("網路錯誤,請稍後再試"); + } const contentType = res.headers.get("Content-Type"); let data = {}; @@ -76,7 +81,6 @@ export function AuthProvider({ children }) { console.error(`API Request Failed [${method} ${endpoint}]:`, data); } const err = new Error(errorMsg); - // 若後端回傳特定欄位(如 remaining_attempts),可一併加到 Error 物件 if (data?.remaining_attempts !== undefined) { err.remaining_attempts = data.remaining_attempts; } @@ -85,7 +89,7 @@ export function AuthProvider({ children }) { return data; } - // App 啟動時從 localStorage 取得 token 並嘗試拉取用戶資料 + // App 啟動時從 localStorage 取得 token 並拉取用戶資料 useEffect(() => { const initAuth = async () => { const savedToken = localStorage.getItem("token"); @@ -98,7 +102,7 @@ export function AuthProvider({ children }) { const data = await apiRequest("/auth/me", "GET", savedToken); setUser(data); } catch (error) { - // Token 失效 => 清空 + // Token 失效,清空 token setToken(null); localStorage.removeItem("token"); } finally { @@ -108,7 +112,7 @@ export function AuthProvider({ children }) { initAuth(); }, []); - // 登入成功後,儲存 Token 並刷新使用者資料 + // 登入成功後,儲存 token 並刷新使用者資料 const loginSuccess = async (loginToken) => { try { const userData = await apiRequest("/auth/me", "GET", loginToken); @@ -123,14 +127,14 @@ export function AuthProvider({ children }) { }; // 根據語系決定首頁路徑 - let homePath = '/'; - if (currentLocale === 'en') { - homePath = '/en'; - } else if (currentLocale === 'ja') { - homePath = '/ja'; + let homePath = "/"; + if (currentLocale === "en") { + homePath = "/en"; + } else if (currentLocale === "ja") { + homePath = "/ja"; } - // 登出 + // 登出:清除 token 與 user,並重導至首頁 const logout = () => { setToken(null); setUser(null); @@ -152,11 +156,10 @@ export function AuthProvider({ children }) { return apiRequest("/auth/send-verification-email", "POST", token, { email }); }; - // 驗證 Email (注意後端為 GET + Redirect 設計,fetch 可能只會拿到 redirect 前的狀態) + // 驗證 Email const verifyEmail = async (verifyToken) => { - // 範例:若前端還是想要直接呼叫 const data = await apiRequest(`/auth/verify-email?token=${verifyToken}`, "GET", null, null); - // 成功後可重新抓取 /auth/me 更新 user 狀態 + // 驗證成功後更新用戶資料 if (token) { const updatedUser = await apiRequest("/auth/me", "GET", token); setUser(updatedUser); diff --git a/src/hooks/useAuthHandler.js b/src/hooks/useAuthHandler.js index c233f7128ae..991a15a6c3f 100644 --- a/src/hooks/useAuthHandler.js +++ b/src/hooks/useAuthHandler.js @@ -3,7 +3,6 @@ import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { useState } from "react"; import { useAuth } from "../context/AuthContext"; - export default function useAuthHandler() { const { loginSuccess } = useAuth(); const [loading, setLoading] = useState(false); @@ -13,7 +12,7 @@ export default function useAuthHandler() { i18n: { currentLocale }, } = useDocusaurusContext(); - // 對應到後端需要的語系 + // 後端所需語系 const serverLang = currentLocale; /** @@ -47,22 +46,18 @@ export default function useAuthHandler() { data = {}; } - // 若非成功 (2xx) if (!res.ok) { const errorMsg = data?.error || "登入失敗"; console.error(`Error ${res.status}:`, data); - switch (res.status) { case 404: - // 使用者不存在 return { success: false, status: 404, - userNotFound: true, // 前端可用於判斷「使用者不存在」 + userNotFound: true, errorMessage: errorMsg, }; case 429: - // 達到最大嘗試次數 (後端封鎖) return { success: false, status: 429, @@ -70,15 +65,13 @@ export default function useAuthHandler() { remainingAttempts: data.remaining_attempts ?? 0, }; case 401: - // 密碼錯誤 / 尚有剩餘次數 return { success: false, status: 401, errorMessage: errorMsg, - remainingAttempts: data.remaining_attempts, // 可能為 0, 1, 2, ... + remainingAttempts: data.remaining_attempts, }; default: - // 其他錯誤 return { success: false, status: res.status, @@ -87,13 +80,12 @@ export default function useAuthHandler() { } } - // 成功情況:帶有 access_token + // 成功情況:回傳 access_token if (data.access_token) { await loginSuccess(data.access_token); return { success: true, status: res.status }; } - // 成功但沒有 token (較罕見) return { success: false, status: res.status, @@ -113,12 +105,13 @@ export default function useAuthHandler() { /** * 註冊 + * @param {object} param0 包含 username 與 password + * @returns {Promise<{ success: boolean; error?: string; pwned?: boolean; }>} */ const register = async ({ username, password }) => { setLoading(true); try { - const fakeEmail = `${username}@example.com`; // 後端必填 email - + const fakeEmail = `${username}@example.com`; // 後端 register 必填 email const res = await fetch("https://api.docsaid.org/auth/register", { method: "POST", headers: { @@ -144,7 +137,6 @@ export default function useAuthHandler() { if (!res.ok) { let errorMsg = data?.error || "Registration failed"; - // 若為 422 => { detail: [ {loc, msg, type} ] } if (Array.isArray(data.detail)) { const msgs = data.detail.map((d) => d.msg).join("; "); errorMsg = msgs || errorMsg; @@ -155,15 +147,13 @@ export default function useAuthHandler() { }; } - // 成功 => 回傳 - if (data.token) { - await loginSuccess(data.token); - } - + // 後端 register 回傳 RegisterResponse,不含 token + // 前端可以提示使用者註冊成功,請進行登入 return { success: !data.pwned, pwned: data.pwned || false, - token: data.token, + // 可回傳註冊後的使用者資料,例如 data.id 等 + userId: data.id, }; } catch (error) { console.error("register error:", error); From 079cd0ca70999dc6a65d5f07f9bc119d0527ec17 Mon Sep 17 00:00:00 2001 From: zephyr-sh Date: Fri, 14 Mar 2025 09:17:51 +0800 Subject: [PATCH 15/25] [C] Update apikey i18n --- src/components/Dashboard/ApiKey/index.js | 433 +++++++++++++-------- src/components/Dashboard/ApiKey/locales.js | 185 +++++++++ 2 files changed, 459 insertions(+), 159 deletions(-) create mode 100644 src/components/Dashboard/ApiKey/locales.js diff --git a/src/components/Dashboard/ApiKey/index.js b/src/components/Dashboard/ApiKey/index.js index 907bd03f1ab..2aaf0155189 100644 --- a/src/components/Dashboard/ApiKey/index.js +++ b/src/components/Dashboard/ApiKey/index.js @@ -1,5 +1,4 @@ // src/components/Dashboard/ApiKey/index.js - import { CopyOutlined, DeleteOutlined, @@ -9,6 +8,7 @@ import { InfoCircleOutlined, PlusOutlined, } from "@ant-design/icons"; +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { Button, Card, @@ -22,35 +22,77 @@ import { message, Modal, Popconfirm, + Progress, Select, Space, Tabs, Tooltip, } from "antd"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import { useAuth } from "../../../context/AuthContext"; import styles from "./index.module.css"; +import { apiKeyLocale } from "./locales"; -// API 基底路徑 const API_BASE_URL = "https://api.docsaid.org/public/token"; -// 依據 usage_plan_id 回傳方案名稱 -const getPlanName = (id) => { +function getPlanName(id, text) { switch (id) { case 1: - return "Basic (Free)"; + return text.planBasic; case 2: - return "Pro (Paid)"; + return text.planProfessional; case 3: - return "PayAsYouGo"; + return text.planPayAsYouGo; default: - return "Unknown"; + return text.planUnknown; } -}; +} + +/** + * 單一 Token 卡片 + */ +function TokenCard({ + item, + copyToken, + handleRevoke, + handleDelete, + openDrawer, + maskToken, +}) { + const { + i18n: { currentLocale }, + } = useDocusaurusContext(); + const text = apiKeyLocale[currentLocale] || apiKeyLocale.en; + const plan = getPlanName(item.usage_plan_id, text); + + const ActionButtons = () => { + if (item.is_active) { + return ( + } + onConfirm={() => handleRevoke(item)} + > + + + ); + } else { + return ( + } + onConfirm={() => handleDelete(item)} + > + + + ); + } + }; -// Token 列表中的單一項目元件 -const TokenCard = ({ item, copyToken, handleRevoke, openDrawer, maskToken }) => { - const plan = getPlanName(item.usage_plan_id); return ( title={
- {item.name || "Untitled Key"} + {item.name || text.defaultTokenName} {plan}
} - extra={ - item.is_active && ( - } - onConfirm={() => handleRevoke(item)} - > - - - ) - } + extra={{}} >
- 到期時間: - {item.expires_at || "永久"} + {text.expiryLabel} + {item.expires_at || text.forever}
- 狀態: - {item.is_active ? "Active" : "Revoked"} + {text.statusLabel} + {item.is_active ? text.active : text.revoked}
- {item.is_active && (
- +
)}
); -}; +} export default function DashboardApiKey() { - const { token } = useAuth(); // 使用者登入 Token + const { + i18n: { currentLocale }, + } = useDocusaurusContext(); + const text = apiKeyLocale[currentLocale] || apiKeyLocale.en; + const { token: userToken } = useAuth(); const [loading, setLoading] = useState(false); const [apiKeys, setApiKeys] = useState([]); @@ -114,95 +149,98 @@ export default function DashboardApiKey() { const [createModalVisible, setCreateModalVisible] = useState(false); const [createForm] = Form.useForm(); - // Drawer 用於查看用量、詳細 + // Drawer 用於查看用量與詳細 const [drawerVisible, setDrawerVisible] = useState(false); - const [drawerToken, setDrawerToken] = useState(null); // 目前開啟 Drawer 所顯示的 Token + const [drawerToken, setDrawerToken] = useState(null); + + // 用來存查詢回來的用量資訊 + const [drawerUsage, setDrawerUsage] = useState(null); - // 是否顯示「明碼」(馬賽克開關) + // 是否顯示明碼 (馬賽克開關) const [showTokenPlain, setShowTokenPlain] = useState(false); - // =============================== - // 1) 載入 Token 列表 - // =============================== - const fetchTokens = useCallback(async () => { - if (!token) return; + // 載入 Token 列表 + const fetchTokens = React.useCallback(async () => { + if (!userToken) return; setLoading(true); try { const res = await fetch(`${API_BASE_URL}/my-tokens`, { - headers: { - Authorization: `Bearer ${token}`, - }, + headers: { Authorization: `Bearer ${userToken}` }, }); if (!res.ok) { throw new Error(`Fetch tokens failed: ${res.status}`); } const data = await res.json(); - // data: [{ jti, usage_plan_id, expires_at, is_active }, ...] setApiKeys(data); } catch (err) { message.error(err.message); } finally { setLoading(false); } - }, [token]); + }, [userToken]); useEffect(() => { fetchTokens(); }, [fetchTokens]); - // =============================== - // 2) 新增 Token - // =============================== - const handleOpenCreateModal = useCallback(() => { + // 打開 "新建 Token" modal + const handleOpenCreateModal = React.useCallback(() => { createForm.resetFields(); setCreateModalVisible(true); }, [createForm]); + // 建立 Token const handleCreateToken = async (values) => { - // 從表單取 usage_plan_id, isPermanent, expires_minutes, name const { usage_plan_id, isPermanent, expires_minutes, name } = values; - // 若勾選永久 => expires_minutes = 999999 const finalExpires = isPermanent ? 999999 : expires_minutes; + if (!userToken) { + message.error(text.notLoggedIn); + return; + } + setLoading(true); try { - const res = await fetch( - `${API_BASE_URL}/?usage_plan_id=${usage_plan_id}&expires_minutes=${finalExpires}`, - { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - } - ); + const nameParam = name ? `&name=${encodeURIComponent(name)}` : ""; + const url = `${API_BASE_URL}/?usage_plan_id=${usage_plan_id}&expires_minutes=${finalExpires}${nameParam}`; + const res = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${userToken}`, + }, + }); if (!res.ok) { const e = await res.json().catch(() => ({})); - throw new Error(e.detail || `Create token failed ${res.status}`); + throw new Error(e.detail || `Create token failed: ${res.status}`); } const data = await res.json(); - // data: { access_token, expires_at, usage_plan_id, ... } - // 用 Modal.success 提示一次性顯示完整 Token Modal.success({ - title: "Token 已建立!", + title: text.createTokenSuccessTitle, content: ( -
-

請妥善保存以下 Token,將不會再次顯示:

+ <> +

{text.createTokenSuccessContent}

{data.access_token}
-
+ ), }); - // 新建列表中的項目 - setApiKeys((prev) => [ - { - jti: data.access_token, - usage_plan_id: data.usage_plan_id, - expires_at: data.expires_at, - is_active: true, - name: name || "", // 目前後端可能沒存 name - }, - ...prev, - ]); + // 解析 jti + const jti = parseJtiFromJWT(data.access_token); + if (!jti) { + message.warning(text.parseJtiWarning); + } + + const newTokenItem = { + jti: jti || `temp-${Date.now()}`, + usage_plan_id: data.usage_plan_id, + expires_at: data.expires_at, + is_active: true, + name: name || "", + rawToken: data.access_token, + }; + setApiKeys((prev) => [newTokenItem, ...prev]); setCreateModalVisible(false); } catch (err) { message.error(err.message); @@ -211,17 +249,28 @@ export default function DashboardApiKey() { } }; - // =============================== - // 3) 撤銷 Token - // =============================== - const handleRevoke = useCallback( + function parseJtiFromJWT(jwtStr) { + try { + const parts = jwtStr.split("."); + if (parts.length !== 3) return null; + const payloadRaw = atob(parts[1]); + const payload = JSON.parse(payloadRaw); + return payload.jti; + } catch { + return null; + } + } + + // 撤銷 Token + const handleRevoke = React.useCallback( async (item) => { + if (!userToken) return; setLoading(true); try { const res = await fetch(`${API_BASE_URL}/revoke`, { method: "POST", headers: { - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${userToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ jti: item.jti }), @@ -230,10 +279,8 @@ export default function DashboardApiKey() { const e = await res.json().catch(() => ({})); throw new Error(e.detail || `Revoke token failed: ${res.status}`); } - message.success("Token 已撤銷"); - // 從列表移除 - setApiKeys((prev) => prev.filter((key) => key.jti !== item.jti)); - // 若 Drawer 正在看該 Token => 關閉 + message.success(text.tokenRevoked); + await fetchTokens(); if (drawerToken && drawerToken.jti === item.jti) { setDrawerVisible(false); setDrawerToken(null); @@ -244,66 +291,94 @@ export default function DashboardApiKey() { setLoading(false); } }, - [token, drawerToken] + [userToken, drawerToken, fetchTokens, text] ); - // =============================== - // 4) 查看詳細(含用量) - // =============================== - const openDrawer = useCallback((item) => { + // 刪除 Token + const handleDelete = React.useCallback( + async (item) => { + if (!userToken) return; + setLoading(true); + try { + const res = await fetch(`${API_BASE_URL}/remove`, { + method: "POST", + headers: { + Authorization: `Bearer ${userToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ jti: item.jti }), + }); + if (!res.ok) { + const e = await res.json().catch(() => ({})); + throw new Error(e.detail || `Delete token failed: ${res.status}`); + } + message.success(text.tokenDeleted); + await fetchTokens(); + if (drawerToken && drawerToken.jti === item.jti) { + setDrawerVisible(false); + setDrawerToken(null); + } + } catch (err) { + message.error(err.message); + } finally { + setLoading(false); + } + }, + [userToken, drawerToken, fetchTokens, text] + ); + + // 打開 Drawer + const openDrawer = React.useCallback((item) => { + setDrawerUsage(null); setDrawerToken(item); setDrawerVisible(true); }, []); - const closeDrawer = useCallback(() => { + // 關閉 Drawer + const closeDrawer = React.useCallback(() => { setDrawerVisible(false); setDrawerToken(null); + setDrawerUsage(null); }, []); - // =============================== - // 5) 查詢用量 - // =============================== - const handleCheckUsage = async (publicToken) => { - if (!publicToken) return; + // 查詢用量 + const handleCheckUsage = async (rawToken) => { + if (!rawToken) { + message.error(text.missingFullToken); + return; + } try { const res = await fetch(`${API_BASE_URL}/usage`, { - headers: { Authorization: `Bearer ${publicToken}` }, + headers: { + Authorization: `Bearer ${rawToken}`, + }, }); if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.detail || `Check usage error: ${res.status}`); } const usageData = await res.json(); - // usageData: { billing_type, used_this_hour, remaining, ... } - if (usageData.billing_type === "rate_limit") { - message.info( - `使用次數:${usageData.used_this_hour},剩餘:${usageData.remaining}` - ); - } else { - message.info(`已用:${usageData.used_this_hour}(Pay-Per-Use, 無限)`); - } + setDrawerUsage(usageData); } catch (err) { message.error(err.message); } }; - // =============================== - // 6) Token 顯示(馬賽克 + 複製) - // =============================== - const copyToken = useCallback(async (val) => { + // 複製 Token + const copyToken = React.useCallback(async (val) => { try { await navigator.clipboard.writeText(val); - message.success("已複製 Token"); + message.success(text.copySuccess); } catch { - message.error("複製失敗"); + message.error(text.copyFailure); } - }, []); + }, [text]); - const maskToken = useCallback( + // 遮罩 Token + const maskToken = React.useCallback( (val) => { if (!val) return ""; - if (showTokenPlain) return val; // 顯示全文 - // 預設只顯示前6 & 後4 + if (showTokenPlain) return val; const front = val.slice(0, 6); const back = val.slice(-4); return front + "****" + back; @@ -311,14 +386,11 @@ export default function DashboardApiKey() { [showTokenPlain] ); - // =============================== - // Render - // =============================== return (
-

My API Keys

-

在此管理、檢視、撤銷你的公開 Token

+

{text.headerTitle}

+

{text.headerDescription}

@@ -328,16 +400,16 @@ export default function DashboardApiKey() { onClick={handleOpenCreateModal} style={{ marginRight: 16 }} > - 建立新 Token + {text.createTokenButton}
setCreateModalVisible(false)} footer={null} @@ -354,46 +426,54 @@ export default function DashboardApiKey() { }} > - + + + - 若勾選,忽略「有效期」 + {text.formPermanentCheckbox} + - - + @@ -411,7 +491,7 @@ export default function DashboardApiKey() { header={
- 我的 Token 列表 + {text.collapseHeader}
} > @@ -424,6 +504,7 @@ export default function DashboardApiKey() { item={item} copyToken={copyToken} handleRevoke={handleRevoke} + handleDelete={handleDelete} openDrawer={openDrawer} maskToken={maskToken} /> @@ -436,8 +517,8 @@ export default function DashboardApiKey() {

- Token (遮罩): - + {text.drawerTokenIdLabel} + copyToken(drawerToken.jti)}> {maskToken(drawerToken.jti)} @@ -455,27 +536,61 @@ export default function DashboardApiKey() {

- 到期時間:{" "} - {drawerToken.expires_at || "永久"} + {text.drawerExpiryLabel}{" "} + {drawerToken.expires_at || text.forever}

- 狀態:{" "} - {drawerToken.is_active ? "Active" : "Revoked"} + {text.drawerStatusLabel}{" "} + {drawerToken.is_active ? text.active : text.revoked}

+ - -

- 這裡可以顯示用量統計,或按下「查詢用量」按鈕。 -

+ +

{text.usageInfoInstruction}

+

+ {text.usageNote} +

+ + {drawerUsage && ( +
+ {drawerUsage.billing_type === "rate_limit" ? ( + <> +

{`${text.usageThisHourLabel}${drawerUsage.used_this_hour} / ${drawerUsage.limit_per_hour}`}

+

{`${text.remainingLabel}${drawerUsage.remaining}`}

+ = + drawerUsage.limit_per_hour + ? "exception" + : "active" + } + /> + + ) : ( + <> +

{`${text.usageThisHourLabel}${drawerUsage.used_this_hour} (${text.payPerUseInfo})`}

+

{drawerUsage.note || ""}

+ + )} +
+ )}
- -

可以放一些額外的說明 / log / 版本紀錄…

+ + +

{text.othersContent}

diff --git a/src/components/Dashboard/ApiKey/locales.js b/src/components/Dashboard/ApiKey/locales.js new file mode 100644 index 00000000000..f1b481cdee1 --- /dev/null +++ b/src/components/Dashboard/ApiKey/locales.js @@ -0,0 +1,185 @@ +// src/components/Dashboard/ApiKey/locales.js +export const apiKeyLocale = { + "zh-hant": { + planBasic: "基本 (免費)", + planProfessional: "專業版", + planPayAsYouGo: "隨用隨付", + planUnknown: "未知", + popconfirmRevokeTitle: "確定撤銷這個 Token 嗎?", + popconfirmDeleteTitle: "確定刪除這個 Token 嗎?", + revokeButton: "撤銷", + deleteButton: "刪除", + defaultTokenName: "My Token", + tokenIdLabel: "Token (ID):", + tooltipCopyToken: "點擊複製 Token JTI", + expiryLabel: "到期時間:", + forever: "永久", + statusLabel: "狀態:", + active: "Active", + revoked: "Revoked", + detailUsageButton: "詳細 / 用量", + headerTitle: "My API Keys", + headerDescription: "管理、檢視、撤銷與刪除你的公開 Token", + createTokenButton: "建立新 Token", + toggleShowTokens: "顯示全部 Token", + toggleHideTokens: "隱藏全部 Token", + createModalTitle: "建立新的公開 Token", + formTokenNameLabel: "Token 名稱", + formTokenNameTooltip: "可給 Token 一個易識別的名稱", + formTokenNamePlaceholder: "如:My DocAligner Key", + formPlanLabel: "方案", + formPermanentLabel: "永久 (不過期)", + formPermanentCheckbox: "若勾選,忽略「有效期」", + formExpiryLabel: "有效期 (分鐘)", + formExpiryValidationMessage: "請輸入有效期", + cancelButton: "取消", + createButton: "建立", + collapseHeader: "我的 Token 列表", + drawerDetail: "詳細", + drawerInfo: "Token Info", + drawerTokenIdLabel: "Token (ID, 遮罩): ", + drawerExpiryLabel: "到期時間:", + drawerStatusLabel: "狀態:", + tabUsageInfo: "用量資訊", + usageInfoInstruction: "按下「查詢用量」按鈕來顯示最新使用量。", + checkUsageButton: "查詢用量", + usageNote: "※ 若 Token 已撤銷 (或未保存完整 JWT),將無法查用量。", + tabOthers: "其他", + othersContent: "你可在此放一些額外資訊、說明或使用範例。", + notLoggedIn: "尚未登入,無法建立 Token", + createTokenSuccessTitle: "Token 已建立!", + createTokenSuccessContent: + "請妥善保存以下 Token(完整 JWT),未來不會再次顯示:", + parseJtiWarning: "無法解析 jti,之後無法查用量", + copySuccess: "已複製", + copyFailure: "複製失敗", + usageThisHourLabel: "本小時用量:", + remainingLabel: "剩餘:", + payPerUseInfo: "Pay-Per-Use, 無上限", + missingFullToken: "無法查用量,缺少完整 Token", + }, + en: { + planBasic: "Basic (Free)", + planProfessional: "Professional", + planPayAsYouGo: "PayAsYouGo", + planUnknown: "Unknown", + popconfirmRevokeTitle: "Are you sure you want to revoke this token?", + popconfirmDeleteTitle: "Are you sure you want to delete this token?", + revokeButton: "Revoke", + deleteButton: "Delete", + defaultTokenName: "My Token", + tokenIdLabel: "Token (ID):", + tooltipCopyToken: "Click to copy token JTI", + expiryLabel: "Expiry:", + forever: "Forever", + statusLabel: "Status:", + active: "Active", + revoked: "Revoked", + detailUsageButton: "Details / Usage", + headerTitle: "My API Keys", + headerDescription: + "Manage, view, revoke, and delete your public tokens", + createTokenButton: "Create New Token", + toggleShowTokens: "Show All Tokens", + toggleHideTokens: "Hide All Tokens", + createModalTitle: "Create New Public Token", + formTokenNameLabel: "Token Name", + formTokenNameTooltip: "Give the token an easily recognizable name", + formTokenNamePlaceholder: "e.g., My DocAligner Key", + formPlanLabel: "Plan", + formPermanentLabel: "Permanent (No Expiry)", + formPermanentCheckbox: "If checked, ignores the expiration period", + formExpiryLabel: "Expiry (minutes)", + formExpiryValidationMessage: "Please enter the expiration time", + cancelButton: "Cancel", + createButton: "Create", + collapseHeader: "My Token List", + drawerDetail: "Details", + drawerInfo: "Token Info", + drawerTokenIdLabel: "Token (ID, Masked): ", + drawerExpiryLabel: "Expiry:", + drawerStatusLabel: "Status:", + tabUsageInfo: "Usage Info", + usageInfoInstruction: + "Click the 'Check Usage' button to display the latest usage.", + checkUsageButton: "Check Usage", + usageNote: + "※ If the token has been revoked (or the full JWT is not saved), usage cannot be checked.", + tabOthers: "Others", + othersContent: + "You can place additional information, instructions, or usage examples here.", + notLoggedIn: "Not logged in, unable to create token", + createTokenSuccessTitle: "Token Created!", + createTokenSuccessContent: + "Please securely save the following token (full JWT); it will not be shown again:", + parseJtiWarning: + "Unable to parse jti; usage cannot be checked later", + copySuccess: "Copied", + copyFailure: "Copy failed", + usageThisHourLabel: "Usage this hour:", + remainingLabel: "Remaining:", + payPerUseInfo: "Pay-Per-Use, No Limit", + missingFullToken: "Unable to check usage, full token is missing", + }, + ja: { + planBasic: "ベーシック (無料)", + planProfessional: "プロフェッショナル", + planPayAsYouGo: "従量課金", + planUnknown: "不明", + popconfirmRevokeTitle: "このトークンを取り消してもよろしいですか?", + popconfirmDeleteTitle: "このトークンを削除してもよろしいですか?", + revokeButton: "取り消し", + deleteButton: "削除", + defaultTokenName: "My Token", + tokenIdLabel: "トークン (ID):", + tooltipCopyToken: "トークンJTIをコピーするにはクリック", + expiryLabel: "有効期限:", + forever: "無期限", + statusLabel: "状態:", + active: "有効", + revoked: "無効", + detailUsageButton: "詳細 / 使用状況", + headerTitle: "マイAPIキー", + headerDescription: "公開トークンの管理、確認、取り消し、削除", + createTokenButton: "新規トークン作成", + toggleShowTokens: "全トークンを表示", + toggleHideTokens: "全トークンを隠す", + createModalTitle: "新しい公開トークンを作成", + formTokenNameLabel: "トークン名", + formTokenNameTooltip: "トークンにわかりやすい名前を付けることができます", + formTokenNamePlaceholder: "例:My DocAligner Key", + formPlanLabel: "プラン", + formPermanentLabel: "永久(期限なし)", + formPermanentCheckbox: "チェックすると、有効期限を無視します", + formExpiryLabel: "有効期限(分)", + formExpiryValidationMessage: "有効期限を入力してください", + cancelButton: "キャンセル", + createButton: "作成", + collapseHeader: "マイトークン一覧", + drawerDetail: "詳細", + drawerInfo: "トークン情報", + drawerTokenIdLabel: "トークン (ID, マスク済み): ", + drawerExpiryLabel: "有効期限:", + drawerStatusLabel: "状態:", + tabUsageInfo: "使用状況情報", + usageInfoInstruction: + "「使用状況確認」ボタンをクリックして最新の使用状況を表示します。", + checkUsageButton: "使用状況確認", + usageNote: + "※ トークンが取り消されている場合(または完全なJWTが保存されていない場合)、使用状況を確認できません。", + tabOthers: "その他", + othersContent: "ここに追加情報、説明、または使用例を配置できます。", + notLoggedIn: "ログインしていないため、トークンを作成できません", + createTokenSuccessTitle: "トークンが作成されました!", + createTokenSuccessContent: + "以下のトークン(完全なJWT)を安全に保存してください。今後再表示されることはありません:", + parseJtiWarning: + "jtiの解析に失敗しました。後で使用状況を確認できなくなります", + copySuccess: "コピーしました", + copyFailure: "コピーに失敗しました", + usageThisHourLabel: "今時間の使用量:", + remainingLabel: "残り:", + payPerUseInfo: "従量課金, 上限なし", + missingFullToken: "使用状況を確認できません。完全なトークンがありません", + }, +}; From bac0eae5f7cb96e9d4537b0726b29ffe85375c75 Mon Sep 17 00:00:00 2001 From: zephyr-sh Date: Fri, 14 Mar 2025 10:54:25 +0800 Subject: [PATCH 16/25] [C] Update api key pages --- .../Dashboard/ApiKey/CreateTokenModal.js | 89 +++ .../index.js => ApiKey/DocAlignerPanel.js} | 61 +- src/components/Dashboard/ApiKey/TokenCard.js | 104 +++ .../Dashboard/ApiKey/UsageOverview.js | 65 ++ src/components/Dashboard/ApiKey/index.js | 648 ++++++------------ .../Dashboard/ApiKey/index.module.css | 154 +++-- src/components/Dashboard/ApiKey/locales.js | 2 +- src/pages/dashboard.js | 35 +- 8 files changed, 572 insertions(+), 586 deletions(-) create mode 100644 src/components/Dashboard/ApiKey/CreateTokenModal.js rename src/components/Dashboard/{ApiUsage/index.js => ApiKey/DocAlignerPanel.js} (54%) create mode 100644 src/components/Dashboard/ApiKey/TokenCard.js create mode 100644 src/components/Dashboard/ApiKey/UsageOverview.js diff --git a/src/components/Dashboard/ApiKey/CreateTokenModal.js b/src/components/Dashboard/ApiKey/CreateTokenModal.js new file mode 100644 index 00000000000..4294746aa27 --- /dev/null +++ b/src/components/Dashboard/ApiKey/CreateTokenModal.js @@ -0,0 +1,89 @@ +// src/components/Dashboard/ApiKey/CreateTokenModal.jsx +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; +import { Button, Checkbox, Form, Input, InputNumber, Modal, Select, Space } from "antd"; +import React from "react"; +import { apiKeyLocale } from "./locales"; + +export default function CreateTokenModal({ + visible, + onCancel, + onSubmit, + loading = false, + defaultValues = { + usage_plan_id: 1, + expires_minutes: 60, + isPermanent: false, + }, +}) { + const [form] = Form.useForm(); + const { + i18n: { currentLocale }, + } = useDocusaurusContext(); + const text = apiKeyLocale[currentLocale] || apiKeyLocale.en; + + return ( + form.resetFields()} + > +
+ + + + + + + + + + {text.formPermanentCheckbox} + + + + + + + + + + + + + +
+ ); +} diff --git a/src/components/Dashboard/ApiUsage/index.js b/src/components/Dashboard/ApiKey/DocAlignerPanel.js similarity index 54% rename from src/components/Dashboard/ApiUsage/index.js rename to src/components/Dashboard/ApiKey/DocAlignerPanel.js index bac5c97c3bc..424f6289c08 100644 --- a/src/components/Dashboard/ApiUsage/index.js +++ b/src/components/Dashboard/ApiKey/DocAlignerPanel.js @@ -1,49 +1,16 @@ -import { Button, Card, Descriptions, Input, message, Space, Tabs } from "antd"; -import React, { useState } from "react"; -import { useAuth } from "../../../context/AuthContext"; +// src/components/Dashboard/ApiKey/DocAlignerPanel.jsx +import { Button, Card, Descriptions, Input, Space, Tabs } from "antd"; +import React from "react"; -/** - * 假設我們只要展示某一支「公開 Token」的當前用量。 - * 也可以列出多筆 Token,讓使用者選擇要查看哪一個 Token。 - * - * 並在畫面下方顯示「DocAligner」的使用範例程式碼 (cURL / Python). - */ -export default function DashboardApiUsage() { - const { token } = useAuth(); // 一般登入的 token (非公開 token) - const [publicToken, setPublicToken] = useState(""); - const [usageData, setUsageData] = useState(null); - const [loading, setLoading] = useState(false); - - // =========== 查詢使用量 =========== - const handleCheckUsage = async () => { - if (!publicToken) { - message.warning("請先輸入公開 Token"); - return; - } - setLoading(true); - try { - const res = await fetch("https://api.docsaid.org/public/token/usage", { - headers: { - Authorization: `Bearer ${publicToken}`, - }, - }); - if (!res.ok) { - const e = await res.json().catch(() => ({})); - throw new Error(e.detail || "Failed to get usage"); - } - const data = await res.json(); - setUsageData(data); - message.success("Usage updated!"); - } catch (err) { - message.error(err.message); - setUsageData(null); - } finally { - setLoading(false); - } - }; - - // 範例:顯示 cURL / Python 的程式碼,使用 docAligner-public - // 你可用 Tabs 切換不同語言 +export default function DocAlignerPanel({ + publicToken, + setPublicToken, + usageData, + setUsageData, + checkLoading, + onCheckUsage +}) { + // 產生範例程式碼 const docalignerCodeCurl = `curl -X POST https://api.docsaid.org/docaligner-public-predict \\ -H "Authorization: Bearer ${publicToken || ""}" \\ -F "file=@/path/to/your/document.jpg"`; @@ -61,7 +28,7 @@ response = requests.post(url, headers=headers, files=files) print(response.json())`; return ( - + setPublicToken(e.target.value)} style={{ width: 400 }} /> - diff --git a/src/components/Dashboard/ApiKey/TokenCard.js b/src/components/Dashboard/ApiKey/TokenCard.js new file mode 100644 index 00000000000..13e6412a102 --- /dev/null +++ b/src/components/Dashboard/ApiKey/TokenCard.js @@ -0,0 +1,104 @@ +// src/components/Dashboard/ApiKey/TokenCard.jsx +import { CopyOutlined, DeleteOutlined, ExclamationCircleOutlined } from "@ant-design/icons"; +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; +import { Button, Card, Popconfirm, Tooltip } from "antd"; +import React from "react"; +import styles from "./index.module.css"; +import { apiKeyLocale } from "./locales"; + +/** 取得方案名稱的輔助函式 */ +function getPlanName(id, text) { + switch (id) { + case 1: + return text.planBasic; + case 2: + return text.planProfessional; + case 3: + return text.planPayAsYouGo; + default: + return text.planUnknown; + } +} + +/** + * 單一 Token 卡片組件 + */ +export default function TokenCard({ + item, + onCopyToken, + onRevokeOrDelete, + onOpenDetail, + maskToken, +}) { + const { + i18n: { currentLocale }, + } = useDocusaurusContext(); + + const text = apiKeyLocale[currentLocale] || apiKeyLocale.en; + const isActive = item.is_active; + const planLabel = getPlanName(item.usage_plan_id, text); + + // 按鈕文字 & Popconfirm 標題 + const buttonText = isActive ? text.revokeButton : text.deleteButton; + const popConfirmTitle = isActive + ? text.popconfirmRevokeTitle + : text.popconfirmDeleteTitle; + + return ( + + {/* 卡片 Header */} +
+
+
{item.name || text.defaultTokenName}
+
{planLabel}
+
+ + {/* Revoke / Delete 按鈕 */} + } + onConfirm={() => onRevokeOrDelete(item)} + > + + +
+ + {/* 主要資訊 Rows */} +
+ +
+ {text.expiryLabel} + {item.expires_at || text.forever} +
+ +
+ {text.statusLabel} + {isActive ? text.active : text.revoked} +
+ + {/* 額外資訊 / Usage Detail 按鈕 */} + {isActive && onOpenDetail && ( +
+ +
+ )} + + ); +} diff --git a/src/components/Dashboard/ApiKey/UsageOverview.js b/src/components/Dashboard/ApiKey/UsageOverview.js new file mode 100644 index 00000000000..9b2618bfc20 --- /dev/null +++ b/src/components/Dashboard/ApiKey/UsageOverview.js @@ -0,0 +1,65 @@ +// src/components/Dashboard/ApiKey/UsageOverview.jsx +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; +import { Card, Progress } from "antd"; +import React from "react"; +import { apiKeyLocale } from "./locales"; + +export default function UsageOverview({ userUsage }) { + const { + i18n: { currentLocale }, + } = useDocusaurusContext(); + + const text = apiKeyLocale[currentLocale] || apiKeyLocale.en; + + if (!userUsage) { + // 可依需求改為 skeleton 或 loading + return ( + +

{text.loadingUsage}

+
+ ); + } + + const { billing_type, used_this_hour, limit_per_hour, remaining } = userUsage; + + return ( + + {billing_type === "rate_limit" ? ( +
+ = limit_per_hour ? "exception" : "normal"} + width={80} + format={() => `${used_this_hour}/${limit_per_hour}`} + /> +
+

{text.usageOverviewTitle}

+

+ {text.usageThisHourLabel}{used_this_hour}/{limit_per_hour} +  •  + {text.remainingLabel}{remaining} +

+
+
+ ) : billing_type === "pay_per_use" ? ( +
+ `${used_this_hour}`} + /> +
+

{text.usageOverviewTitle}

+

+ {text.usageThisHourLabel}{used_this_hour} (Pay-As-You-Go) +

+
+
+ ) : ( +

Unknown billing type

+ )} +
+ ); +} diff --git a/src/components/Dashboard/ApiKey/index.js b/src/components/Dashboard/ApiKey/index.js index 2aaf0155189..e4a1e2eea68 100644 --- a/src/components/Dashboard/ApiKey/index.js +++ b/src/components/Dashboard/ApiKey/index.js @@ -1,8 +1,6 @@ // src/components/Dashboard/ApiKey/index.js import { CopyOutlined, - DeleteOutlined, - ExclamationCircleOutlined, EyeInvisibleOutlined, EyeOutlined, InfoCircleOutlined, @@ -11,156 +9,55 @@ import { import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { Button, - Card, - Checkbox, Collapse, Drawer, - Form, - Input, - InputNumber, List, - message, - Modal, - Popconfirm, - Progress, - Select, - Space, - Tabs, - Tooltip, + message } from "antd"; -import React, { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { useAuth } from "../../../context/AuthContext"; + import styles from "./index.module.css"; import { apiKeyLocale } from "./locales"; -const API_BASE_URL = "https://api.docsaid.org/public/token"; - -function getPlanName(id, text) { - switch (id) { - case 1: - return text.planBasic; - case 2: - return text.planProfessional; - case 3: - return text.planPayAsYouGo; - default: - return text.planUnknown; - } -} - -/** - * 單一 Token 卡片 - */ -function TokenCard({ - item, - copyToken, - handleRevoke, - handleDelete, - openDrawer, - maskToken, -}) { - const { - i18n: { currentLocale }, - } = useDocusaurusContext(); - const text = apiKeyLocale[currentLocale] || apiKeyLocale.en; - const plan = getPlanName(item.usage_plan_id, text); - - const ActionButtons = () => { - if (item.is_active) { - return ( - } - onConfirm={() => handleRevoke(item)} - > - - - ); - } else { - return ( - } - onConfirm={() => handleDelete(item)} - > - - - ); - } - }; +import CreateTokenModal from "./CreateTokenModal"; +import DocAlignerPanel from "./DocAlignerPanel"; +import TokenCard from "./TokenCard"; +import UsageOverview from "./UsageOverview"; - return ( - - - - {item.name || text.defaultTokenName} - - {plan} -
- } - extra={{}} - > - -
- {text.expiryLabel} - {item.expires_at || text.forever} -
-
- {text.statusLabel} - {item.is_active ? text.active : text.revoked} -
- {item.is_active && ( -
- -
- )} - - - ); -} +const API_BASE_URL = "https://api.docsaid.org/public/token"; export default function DashboardApiKey() { + const { token: userToken } = useAuth(); const { i18n: { currentLocale }, } = useDocusaurusContext(); const text = apiKeyLocale[currentLocale] || apiKeyLocale.en; - const { token: userToken } = useAuth(); - const [loading, setLoading] = useState(false); + + // ===== Global states ===== const [apiKeys, setApiKeys] = useState([]); + const [userUsage, setUserUsage] = useState(null); + const [loading, setLoading] = useState(false); - // 新增 Token 的 Modal + // 控制 Token 明碼 + const [showTokenPlain, setShowTokenPlain] = useState(false); + + // 建立 Token Modal const [createModalVisible, setCreateModalVisible] = useState(false); - const [createForm] = Form.useForm(); - // Drawer 用於查看用量與詳細 + // Drawer: 詳細資訊 const [drawerVisible, setDrawerVisible] = useState(false); - const [drawerToken, setDrawerToken] = useState(null); + const [detailToken, setDetailToken] = useState(null); - // 用來存查詢回來的用量資訊 - const [drawerUsage, setDrawerUsage] = useState(null); - - // 是否顯示明碼 (馬賽克開關) - const [showTokenPlain, setShowTokenPlain] = useState(false); + // ===== DocAligner Panel: 公開 Token 測試 ===== + const [publicToken, setPublicToken] = useState(""); + const [usageData, setUsageData] = useState(null); + const [checkLoading, setCheckLoading] = useState(false); - // 載入 Token 列表 - const fetchTokens = React.useCallback(async () => { + // --------------------------------------- + // 1) 載入 Token 列表 + // --------------------------------------- + const fetchTokens = useCallback(async () => { if (!userToken) return; setLoading(true); try { @@ -179,19 +76,36 @@ export default function DashboardApiKey() { } }, [userToken]); + // --------------------------------------- + // 2) 載入使用者整體用量 + // --------------------------------------- + const fetchUserUsage = useCallback(async () => { + if (!userToken) return; + try { + const res = await fetch(`${API_BASE_URL}/user-usage`, { + headers: { Authorization: `Bearer ${userToken}` }, + }); + if (!res.ok) { + const e = await res.json().catch(() => ({})); + throw new Error(e.detail || `Fetch user usage error: ${res.status}`); + } + const usage = await res.json(); + setUserUsage(usage); + } catch (err) { + console.error(err); + } + }, [userToken]); + useEffect(() => { fetchTokens(); - }, [fetchTokens]); - - // 打開 "新建 Token" modal - const handleOpenCreateModal = React.useCallback(() => { - createForm.resetFields(); - setCreateModalVisible(true); - }, [createForm]); - - // 建立 Token - const handleCreateToken = async (values) => { - const { usage_plan_id, isPermanent, expires_minutes, name } = values; + fetchUserUsage(); + }, [fetchTokens, fetchUserUsage]); + + // --------------------------------------- + // 3) 建立 Token + // --------------------------------------- + const handleCreateToken = async (formValues) => { + const { usage_plan_id, isPermanent, expires_minutes, name } = formValues; const finalExpires = isPermanent ? 999999 : expires_minutes; if (!userToken) { @@ -201,13 +115,12 @@ export default function DashboardApiKey() { setLoading(true); try { + const planParam = `usage_plan_id=${usage_plan_id}`; const nameParam = name ? `&name=${encodeURIComponent(name)}` : ""; - const url = `${API_BASE_URL}/?usage_plan_id=${usage_plan_id}&expires_minutes=${finalExpires}${nameParam}`; + const url = `${API_BASE_URL}/?${planParam}&expires_minutes=${finalExpires}${nameParam}`; const res = await fetch(url, { method: "POST", - headers: { - Authorization: `Bearer ${userToken}`, - }, + headers: { Authorization: `Bearer ${userToken}` }, }); if (!res.ok) { const e = await res.json().catch(() => ({})); @@ -215,32 +128,20 @@ export default function DashboardApiKey() { } const data = await res.json(); - Modal.success({ - title: text.createTokenSuccessTitle, - content: ( - <> -

{text.createTokenSuccessContent}

-
{data.access_token}
- - ), - }); - - // 解析 jti - const jti = parseJtiFromJWT(data.access_token); - if (!jti) { - message.warning(text.parseJtiWarning); - } + message.success(text.createTokenSuccessTitle); + // 也可用 Modal.success() + // -> 省略: 你可自行放 Token 的提示 - const newTokenItem = { - jti: jti || `temp-${Date.now()}`, - usage_plan_id: data.usage_plan_id, + const jti = _parseJti(data.access_token) || `temp-${Date.now()}`; + const newItem = { + jti, + usage_plan_id: data.usage_plan_id || usage_plan_id, expires_at: data.expires_at, is_active: true, name: name || "", - rawToken: data.access_token, }; - setApiKeys((prev) => [newTokenItem, ...prev]); + setApiKeys((prev) => [newItem, ...prev]); setCreateModalVisible(false); } catch (err) { message.error(err.message); @@ -249,7 +150,7 @@ export default function DashboardApiKey() { } }; - function parseJtiFromJWT(jwtStr) { + function _parseJti(jwtStr) { try { const parts = jwtStr.split("."); if (parts.length !== 3) return null; @@ -261,338 +162,205 @@ export default function DashboardApiKey() { } } - // 撤銷 Token - const handleRevoke = React.useCallback( - async (item) => { - if (!userToken) return; - setLoading(true); - try { - const res = await fetch(`${API_BASE_URL}/revoke`, { - method: "POST", - headers: { - Authorization: `Bearer ${userToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ jti: item.jti }), - }); - if (!res.ok) { - const e = await res.json().catch(() => ({})); - throw new Error(e.detail || `Revoke token failed: ${res.status}`); - } - message.success(text.tokenRevoked); - await fetchTokens(); - if (drawerToken && drawerToken.jti === item.jti) { - setDrawerVisible(false); - setDrawerToken(null); - } - } catch (err) { - message.error(err.message); - } finally { - setLoading(false); + // --------------------------------------- + // 4) 撤銷 / 刪除 Token + // --------------------------------------- + const handleRevokeOrDelete = async (tokenItem) => { + if (!userToken) return; + setLoading(true); + const endpoint = tokenItem.is_active ? "revoke" : "remove"; + try { + const res = await fetch(`${API_BASE_URL}/${endpoint}`, { + method: "POST", + headers: { + Authorization: `Bearer ${userToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ jti: tokenItem.jti }), + }); + if (!res.ok) { + const e = await res.json().catch(() => ({})); + throw new Error(e.detail || `Operation failed: ${res.status}`); } - }, - [userToken, drawerToken, fetchTokens, text] - ); + message.success(tokenItem.is_active ? text.tokenRevoked : text.tokenDeleted); + await fetchTokens(); - // 刪除 Token - const handleDelete = React.useCallback( - async (item) => { - if (!userToken) return; - setLoading(true); - try { - const res = await fetch(`${API_BASE_URL}/remove`, { - method: "POST", - headers: { - Authorization: `Bearer ${userToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ jti: item.jti }), - }); - if (!res.ok) { - const e = await res.json().catch(() => ({})); - throw new Error(e.detail || `Delete token failed: ${res.status}`); - } - message.success(text.tokenDeleted); - await fetchTokens(); - if (drawerToken && drawerToken.jti === item.jti) { - setDrawerVisible(false); - setDrawerToken(null); - } - } catch (err) { - message.error(err.message); - } finally { - setLoading(false); + // 如果 Drawer 正在顯示這個 Token,關閉 + if (detailToken && detailToken.jti === tokenItem.jti) { + setDrawerVisible(false); + setDetailToken(null); } - }, - [userToken, drawerToken, fetchTokens, text] - ); + } catch (err) { + message.error(err.message); + } finally { + setLoading(false); + } + }; - // 打開 Drawer - const openDrawer = React.useCallback((item) => { - setDrawerUsage(null); - setDrawerToken(item); + // --------------------------------------- + // 5) Drawer 打開 / 關閉 + // --------------------------------------- + const openDrawer = (tokenItem) => { + setDetailToken(tokenItem); setDrawerVisible(true); - }, []); - - // 關閉 Drawer - const closeDrawer = React.useCallback(() => { + }; + const closeDrawer = () => { setDrawerVisible(false); - setDrawerToken(null); - setDrawerUsage(null); - }, []); - - // 查詢用量 - const handleCheckUsage = async (rawToken) => { - if (!rawToken) { - message.error(text.missingFullToken); + setDetailToken(null); + }; + + // --------------------------------------- + // 6) 複製 Token + // --------------------------------------- + const copyToken = async (tokenId) => { + try { + await navigator.clipboard.writeText(tokenId); + message.success(text.copySuccess); + } catch { + message.error(text.copyFailure); + } + }; + + // --------------------------------------- + // 7) 遮罩 Token + // --------------------------------------- + const maskToken = (val) => { + if (!val) return ""; + if (showTokenPlain) return val; + return val.slice(0, 6) + "****" + val.slice(-4); + }; + + // --------------------------------------- + // 8) DocAlignerPanel:查詢 usage + // --------------------------------------- + const handleCheckUsage = async () => { + if (!publicToken) { + message.warning("請先輸入公開 Token"); return; } + setCheckLoading(true); try { const res = await fetch(`${API_BASE_URL}/usage`, { - headers: { - Authorization: `Bearer ${rawToken}`, - }, + headers: { Authorization: `Bearer ${publicToken}` }, }); if (!res.ok) { const e = await res.json().catch(() => ({})); - throw new Error(e.detail || `Check usage error: ${res.status}`); + throw new Error(e.detail || "Failed to get usage"); } - const usageData = await res.json(); - setDrawerUsage(usageData); + const data = await res.json(); + setUsageData(data); + message.success("Usage updated!"); } catch (err) { message.error(err.message); + setUsageData(null); + } finally { + setCheckLoading(false); } }; - // 複製 Token - const copyToken = React.useCallback(async (val) => { - try { - await navigator.clipboard.writeText(val); - message.success(text.copySuccess); - } catch { - message.error(text.copyFailure); - } - }, [text]); - - // 遮罩 Token - const maskToken = React.useCallback( - (val) => { - if (!val) return ""; - if (showTokenPlain) return val; - const front = val.slice(0, 6); - const back = val.slice(-4); - return front + "****" + back; - }, - [showTokenPlain] - ); - return (
+ {/* 頁面標題 */}

{text.headerTitle}

{text.headerDescription}

+ {/* 用量概覽 */} + + + {/* 操作按鈕區 */}
-
- setCreateModalVisible(false)} - footer={null} - destroyOnClose - > -
- - - - - - - - - - {text.formPermanentCheckbox} - - - - - - - - - - - - -
- -
- + + + {text.collapseHeader} +
+ } > - - - {text.collapseHeader} -
- } - > - item.jti} - renderItem={(item) => ( - - )} - /> - - - + item.jti} + renderItem={(item) => ( + + )} + /> + + + + + + + + {/* 建立 Token 的 Modal */} + setCreateModalVisible(false)} + onSubmit={handleCreateToken} + loading={loading} + /> + {/* Drawer - Token 詳細資訊 */} - {drawerToken && ( + {detailToken && ( <>

- {text.drawerTokenIdLabel} - - copyToken(drawerToken.jti)}> - {maskToken(drawerToken.jti)} - - - + {text.drawerTokenIdLabel}{" "} + copyToken(detailToken.jti)}> + {maskToken(detailToken.jti)} + +

{text.drawerExpiryLabel}{" "} - {drawerToken.expires_at || text.forever} + {detailToken.expires_at || text.forever}

{text.drawerStatusLabel}{" "} - {drawerToken.is_active ? text.active : text.revoked} + {detailToken.is_active ? text.active : text.revoked}

- - - -

{text.usageInfoInstruction}

- -

- {text.usageNote} -

- - {drawerUsage && ( -
- {drawerUsage.billing_type === "rate_limit" ? ( - <> -

{`${text.usageThisHourLabel}${drawerUsage.used_this_hour} / ${drawerUsage.limit_per_hour}`}

-

{`${text.remainingLabel}${drawerUsage.remaining}`}

- = - drawerUsage.limit_per_hour - ? "exception" - : "active" - } - /> - - ) : ( - <> -

{`${text.usageThisHourLabel}${drawerUsage.used_this_hour} (${text.payPerUseInfo})`}

-

{drawerUsage.note || ""}

- - )} -
- )} -
- - -

{text.othersContent}

-
-
)}
diff --git a/src/components/Dashboard/ApiKey/index.module.css b/src/components/Dashboard/ApiKey/index.module.css index 0eb7c0aedaf..3da1538da06 100644 --- a/src/components/Dashboard/ApiKey/index.module.css +++ b/src/components/Dashboard/ApiKey/index.module.css @@ -1,77 +1,97 @@ +/* src/components/Dashboard/ApiKey/index.module.css */ + +/* 容器 */ .apiKeyContainer { - max-width: 900px; - margin: 0 auto; - padding: 24px; - text-align: left; /* 左上角對齊 */ - } + max-width: 900px; + margin: 0 auto; + padding: 24px; + text-align: left; /* 左上角對齊 */ +} + +/* 頁面標題 */ +.header { + margin-bottom: 16px; + text-align: left; +} +.header h2 { + font-size: 1.6rem; + margin: 0 0 4px 0; +} +.header p { + color: #777; + margin: 0; +} + +/* 操作按鈕列 */ +.actions { + margin-bottom: 16px; + display: flex; + gap: 8px; +} - .header { - margin-bottom: 16px; - text-align: left; /* 左上角對齊 */ - } - .header h2 { - font-size: 1.6rem; - margin: 0 0 4px 0; - } - .header p { - color: #777; - margin: 0; - } +/* 用於顯示建立完成後的 Token */ +.tokenBox { + background: #f5f5f5; + padding: 8px; + word-break: break-all; + font-family: monospace; + border: 1px solid #ccc; + border-radius: 4px; +} - .actions { - margin-bottom: 16px; - text-align: left; /* 左對齊,也可 right */ - display: flex; - gap: 8px; - } +/* Collapse 外框 */ +.collapseRoot { + background-color: #fff; + border: 1px solid #e8e8e8; + border-radius: 8px; +} - .tokenBox { - background: #f5f5f5; - padding: 8px; - word-break: break-all; - font-family: monospace; - border: 1px solid #ccc; - border-radius: 4px; - } +/* List.Item */ +.tokenListItem { + border: none !important; + margin-bottom: 16px; +} - .collapseRoot { - background-color: #fff; - border: 1px solid #e8e8e8; - border-radius: 8px; - } +/* Token 卡片 */ +.tokenCard { + width: 100%; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + transition: box-shadow 0.2s ease; +} - .tokenListItem { - border: none !important; - margin-bottom: 16px; - } +.tokenCard:hover { + box-shadow: 0 2px 6px rgba(0,0,0,0.15); +} - .tokenCard { - width: 100%; - border-radius: 8px; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); - } +/* 卡片頂部區域 */ +.tokenCardHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} - .tokenTitle { - display: flex; - align-items: center; - justify-content: space-between; - } +/* Token 名稱 */ +.tokenName { + font-weight: 600; + font-size: 1rem; + margin-bottom: 4px; +} - .tokenName { - font-weight: 600; - font-size: 1rem; - } - .tokenPlan { - font-size: 0.9rem; - color: #888; - } +/* 方案標籤 */ +.planLabel { + font-size: 0.85rem; + color: #666; +} - .tokenItemRow { - margin: 4px 0; - line-height: 1.6; - } - .label { - font-weight: 500; - color: #555; - margin-right: 4px; - } +/* 一般欄位 */ +.tokenRow { + margin: 4px 0; + line-height: 1.6; +} +.label { + font-weight: 500; + color: #555; + margin-right: 4px; +} diff --git a/src/components/Dashboard/ApiKey/locales.js b/src/components/Dashboard/ApiKey/locales.js index f1b481cdee1..d4d09aeb121 100644 --- a/src/components/Dashboard/ApiKey/locales.js +++ b/src/components/Dashboard/ApiKey/locales.js @@ -18,7 +18,7 @@ export const apiKeyLocale = { active: "Active", revoked: "Revoked", detailUsageButton: "詳細 / 用量", - headerTitle: "My API Keys", + headerTitle: "我的 API Keys", headerDescription: "管理、檢視、撤銷與刪除你的公開 Token", createTokenButton: "建立新 Token", toggleShowTokens: "顯示全部 Token", diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index 2c54bfc0f66..357611ff0b0 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -1,12 +1,11 @@ // src/pages/dashboard.js import { - DatabaseOutlined, HomeOutlined, KeyOutlined, MenuFoldOutlined, MenuUnfoldOutlined, PoweroffOutlined, - UserOutlined, + UserOutlined } from "@ant-design/icons"; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import Layout from "@theme/Layout"; @@ -27,7 +26,6 @@ import { useAuth } from "../context/AuthContext"; // Dashboard 子頁面 import DashboardApiKey from "../components/Dashboard/ApiKey"; -import DashboardApiUsage from "../components/Dashboard/ApiUsage"; import DashboardMyInfo from "../components/Dashboard/MyInfo"; const { Header: AntHeader, Sider, Content, Footer } = AntLayout; @@ -43,23 +41,18 @@ const localeText = { menu: { myinfo: "我的資訊", apikey: "我的 API Key", - apiusage: "API 使用紀錄", }, }, breadcrumb: { dashboard: "我的後台", myinfo: "我的資訊", apikey: "我的 API Key", - apiusage: "API 使用紀錄", undefined: "未定義", }, userMenu: { backHome: "回主站", logout: "登出", - }, - footer: { - text: "© {year} My Company. All rights reserved.", - }, + } }, en: { dashboardTitle: "Dashboard", @@ -71,23 +64,18 @@ const localeText = { menu: { myinfo: "My Information", apikey: "My API Key", - apiusage: "API Usage", }, }, breadcrumb: { dashboard: "Dashboard", myinfo: "My Information", apikey: "My API Key", - apiusage: "API Usage", undefined: "Undefined", }, userMenu: { backHome: "Back to Site", logout: "Logout", - }, - footer: { - text: "© {year} My Company. All rights reserved.", - }, + } }, ja: { dashboardTitle: "ダッシュボード", @@ -99,23 +87,18 @@ const localeText = { menu: { myinfo: "マイ情報", apikey: "マイAPIキー", - apiusage: "API利用状況", }, }, breadcrumb: { dashboard: "ダッシュボード", myinfo: "マイ情報", apikey: "マイAPIキー", - apiusage: "API利用状況", undefined: "未定義", }, userMenu: { backHome: "サイトへ戻る", logout: "ログアウト", - }, - footer: { - text: "© {year} My Company. All rights reserved.", - }, + } }, }; @@ -141,8 +124,6 @@ export default function DashboardPage() { return ; case "apikey": return ; - case "apiusage": - return ; default: return null; } @@ -154,8 +135,6 @@ export default function DashboardPage() { return text.breadcrumb.myinfo; case "apikey": return text.breadcrumb.apikey; - case "apiusage": - return text.breadcrumb.apiusage; default: return text.breadcrumb.undefined; } @@ -236,7 +215,6 @@ export default function DashboardPage() { items={[ { key: "myinfo", icon: , label: text.sider.menu.myinfo }, { key: "apikey", icon: , label: text.sider.menu.apikey }, - { key: "apiusage", icon: , label: text.sider.menu.apiusage }, ]} /> @@ -283,11 +261,6 @@ export default function DashboardPage() { {contentComponent} -
-
- {text.footer.text.replace("{year}", new Date().getFullYear())} -
-
From 44358b6351e4e94d154f1582a60ef5aa60a73aa7 Mon Sep 17 00:00:00 2001 From: zephyr-sh Date: Sat, 15 Mar 2025 08:55:31 +0800 Subject: [PATCH 17/25] [C] Update api key usage --- .../Dashboard/ApiKey/ApiUsageExamples.js | 236 +++++++++ .../Dashboard/ApiKey/CreateTokenModal.js | 46 +- .../Dashboard/ApiKey/DocAlignerPanel.js | 36 +- src/components/Dashboard/ApiKey/TokenCard.js | 91 ++-- .../Dashboard/ApiKey/UsageOverview.js | 38 +- src/components/Dashboard/ApiKey/index.js | 451 ++++++++++-------- .../Dashboard/ApiKey/index.module.css | 53 +- src/components/Dashboard/ApiKey/locales.js | 50 +- 8 files changed, 689 insertions(+), 312 deletions(-) create mode 100644 src/components/Dashboard/ApiKey/ApiUsageExamples.js diff --git a/src/components/Dashboard/ApiKey/ApiUsageExamples.js b/src/components/Dashboard/ApiKey/ApiUsageExamples.js new file mode 100644 index 00000000000..b2e96299321 --- /dev/null +++ b/src/components/Dashboard/ApiKey/ApiUsageExamples.js @@ -0,0 +1,236 @@ +// src/components/Dashboard/ApiKey/ApiUsageExamples.jsx +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; +import { Card, Tabs } from "antd"; +import React from "react"; +import { apiKeyLocale } from "./locales"; + +const { TabPane } = Tabs; + +export default function ApiUsageExamples() { + const { + i18n: { currentLocale }, + } = useDocusaurusContext(); + const text = apiKeyLocale[currentLocale] || apiKeyLocale.en; + + const examples = { + docaligner: { + curl: `curl -X POST https://api.docsaid.org/docaligner-public-predict \\ + -H "Authorization: Bearer " \\ + -F "file=@/path/to/your/document.jpg"`, + python: `import requests + +url = "https://api.docsaid.org/docaligner-public-predict" +headers = {"Authorization": "Bearer "} +files = {"file": open("/path/to/your/document.jpg", "rb")} +response = requests.post(url, headers=headers, files=files) +print(response.json())`, + node: `const axios = require('axios'); +const FormData = require('form-data'); +const fs = require('fs'); + +const form = new FormData(); +form.append('file', fs.createReadStream('/path/to/your/document.jpg')); + +axios.post('https://api.docsaid.org/docaligner-public-predict', form, { + headers: { + ...form.getHeaders(), + 'Authorization': 'Bearer ' + } +}) +.then(response => { + console.log(response.data); +}) +.catch(error => { + console.error(error); +});`, + javascript: `fetch("https://api.docsaid.org/docaligner-public-predict", { + method: "POST", + headers: { + "Authorization": "Bearer " + }, + body: new FormData(document.getElementById("uploadForm")) +}) +.then(response => response.json()) +.then(data => console.log(data)) +.catch(error => console.error(error));`, + java: `OkHttpClient client = new OkHttpClient(); + +MediaType mediaType = MediaType.parse("multipart/form-data"); +RequestBody body = new MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("file", "/path/to/your/document.jpg", + RequestBody.create(new File("/path/to/your/document.jpg"), MediaType.parse("image/jpeg"))) + .build(); + +Request request = new Request.Builder() + .url("https://api.docsaid.org/docaligner-public-predict") + .post(body) + .addHeader("Authorization", "Bearer ") + .build(); + +Response response = client.newCall(request).execute(); +System.out.println(response.body().string());`, + ruby: `require 'net/http' +require 'uri' + +uri = URI.parse("https://api.docsaid.org/docaligner-public-predict") +request = Net::HTTP::Post.new(uri) +request["Authorization"] = "Bearer " +form_data = [['file', File.open('/path/to/your/document.jpg')]] +request.set_form form_data, 'multipart/form-data' + +response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http| + http.request(request) +end + +puts response.body`, + }, + mrzscanner: { + curl: `curl -X POST https://api.docsaid.org/mrzscanner-public-predict \\ + -H "Authorization: Bearer " \\ + -F "file=@/path/to/your/document.jpg"`, + python: `import requests + +url = "https://api.docsaid.org/mrzscanner-public-predict" +headers = {"Authorization": "Bearer "} +files = {"file": open("/path/to/your/document.jpg", "rb")} +response = requests.post(url, headers=headers, files=files) +print(response.json())`, + node: `const axios = require('axios'); +const FormData = require('form-data'); +const fs = require('fs'); + +const form = new FormData(); +form.append('file', fs.createReadStream('/path/to/your/document.jpg')); + +axios.post('https://api.docsaid.org/mrzscanner-public-predict', form, { + headers: { + ...form.getHeaders(), + 'Authorization': 'Bearer ' + } +}) +.then(response => { + console.log(response.data); +}) +.catch(error => { + console.error(error); +});`, + javascript: `fetch("https://api.docsaid.org/mrzscanner-public-predict", { + method: "POST", + headers: { + "Authorization": "Bearer " + }, + body: new FormData(document.getElementById("uploadForm")) +}) +.then(response => response.json()) +.then(data => console.log(data)) +.catch(error => console.error(error));`, + java: `OkHttpClient client = new OkHttpClient(); + +MediaType mediaType = MediaType.parse("multipart/form-data"); +RequestBody body = new MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("file", "/path/to/your/document.jpg", + RequestBody.create(new File("/path/to/your/document.jpg"), MediaType.parse("image/jpeg"))) + .build(); + +Request request = new Request.Builder() + .url("https://api.docsaid.org/mrzscanner-public-predict") + .post(body) + .addHeader("Authorization", "Bearer ") + .build(); + +Response response = client.newCall(request).execute(); +System.out.println(response.body().string());`, + ruby: `require 'net/http' +require 'uri' + +uri = URI.parse("https://api.docsaid.org/mrzscanner-public-predict") +request = Net::HTTP::Post.new(uri) +request["Authorization"] = "Bearer " +form_data = [['file', File.open('/path/to/your/document.jpg')]] +request.set_form form_data, 'multipart/form-data' + +response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http| + http.request(request) +end + +puts response.body`, + }, + }; + + return ( + + + + + +
+                {examples.docaligner.curl}
+              
+
+ +
+                {examples.docaligner.python}
+              
+
+ +
+                {examples.docaligner.node}
+              
+
+ +
+                {examples.docaligner.javascript}
+              
+
+ +
+                {examples.docaligner.java}
+              
+
+ +
+                {examples.docaligner.ruby}
+              
+
+
+
+ + + +
+                {examples.mrzscanner.curl}
+              
+
+ +
+                {examples.mrzscanner.python}
+              
+
+ +
+                {examples.mrzscanner.node}
+              
+
+ +
+                {examples.mrzscanner.javascript}
+              
+
+ +
+                {examples.mrzscanner.java}
+              
+
+ +
+                {examples.mrzscanner.ruby}
+              
+
+
+
+
+
+ ); +} diff --git a/src/components/Dashboard/ApiKey/CreateTokenModal.js b/src/components/Dashboard/ApiKey/CreateTokenModal.js index 4294746aa27..86a76fbd4c2 100644 --- a/src/components/Dashboard/ApiKey/CreateTokenModal.js +++ b/src/components/Dashboard/ApiKey/CreateTokenModal.js @@ -1,7 +1,7 @@ -// src/components/Dashboard/ApiKey/CreateTokenModal.jsx import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; -import { Button, Checkbox, Form, Input, InputNumber, Modal, Select, Space } from "antd"; -import React from "react"; +import { Button, Checkbox, Form, Input, InputNumber, Modal, Space } from "antd"; +import PropTypes from "prop-types"; +import React, { useEffect } from "react"; import { apiKeyLocale } from "./locales"; export default function CreateTokenModal({ @@ -10,9 +10,8 @@ export default function CreateTokenModal({ onSubmit, loading = false, defaultValues = { - usage_plan_id: 1, expires_minutes: 60, - isPermanent: false, + isLongTerm: false, // 改用 isLongTerm }, }) { const [form] = Form.useForm(); @@ -21,6 +20,10 @@ export default function CreateTokenModal({ } = useDocusaurusContext(); const text = apiKeyLocale[currentLocale] || apiKeyLocale.en; + useEffect(() => { + form.setFieldsValue(defaultValues); + }, [defaultValues, form]); + return ( + {/* isLongTerm: 勾選即用 1年 */} - - - - - {text.formPermanentCheckbox} + 勾選後自動設定 1 年效期 (525600 分鐘) @@ -87,3 +79,15 @@ export default function CreateTokenModal({ ); } + +CreateTokenModal.propTypes = { + visible: PropTypes.bool.isRequired, + onCancel: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + loading: PropTypes.bool, + defaultValues: PropTypes.shape({ + expires_minutes: PropTypes.number, + isLongTerm: PropTypes.bool, + name: PropTypes.string, + }), +}; diff --git a/src/components/Dashboard/ApiKey/DocAlignerPanel.js b/src/components/Dashboard/ApiKey/DocAlignerPanel.js index 424f6289c08..3966e3d004f 100644 --- a/src/components/Dashboard/ApiKey/DocAlignerPanel.js +++ b/src/components/Dashboard/ApiKey/DocAlignerPanel.js @@ -1,6 +1,8 @@ // src/components/Dashboard/ApiKey/DocAlignerPanel.jsx +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { Button, Card, Descriptions, Input, Space, Tabs } from "antd"; import React from "react"; +import { apiKeyLocale } from "./locales"; export default function DocAlignerPanel({ publicToken, @@ -8,18 +10,22 @@ export default function DocAlignerPanel({ usageData, setUsageData, checkLoading, - onCheckUsage + onCheckUsage, }) { - // 產生範例程式碼 + const { + i18n: { currentLocale }, + } = useDocusaurusContext(); + const text = apiKeyLocale[currentLocale] || apiKeyLocale.en; + const docalignerCodeCurl = `curl -X POST https://api.docsaid.org/docaligner-public-predict \\ - -H "Authorization: Bearer ${publicToken || ""}" \\ + -H "Authorization: Bearer ${publicToken || "<${text.docAlignerYourPublicToken}>" }" \\ -F "file=@/path/to/your/document.jpg"`; const docalignerCodePython = `import requests url = "https://api.docsaid.org/docaligner-public-predict" headers = { - "Authorization": "Bearer ${publicToken || ""}" + "Authorization": "Bearer ${publicToken || "<${text.docAlignerYourPublicToken}>" }" } files = { "file": open("/path/to/your/document.jpg", "rb") @@ -31,27 +37,33 @@ print(response.json())`; setPublicToken(e.target.value)} style={{ width: 400 }} /> {usageData && ( - {usageData.billing_type} - {usageData.used_this_hour ?? "-"} + + {usageData.billing_type} + + + {usageData.used_this_hour ?? "-"} + {usageData.remaining !== undefined && ( - {usageData.remaining} + + {usageData.remaining} + )} )} - + -{docalignerCodeCurl} + {docalignerCodeCurl} ), }, @@ -68,7 +80,7 @@ print(response.json())`; label: "Python", children: (
-{docalignerCodePython}
+                  {docalignerCodePython}
                 
), }, diff --git a/src/components/Dashboard/ApiKey/TokenCard.js b/src/components/Dashboard/ApiKey/TokenCard.js index 13e6412a102..4eb099bba10 100644 --- a/src/components/Dashboard/ApiKey/TokenCard.js +++ b/src/components/Dashboard/ApiKey/TokenCard.js @@ -1,59 +1,55 @@ -// src/components/Dashboard/ApiKey/TokenCard.jsx -import { CopyOutlined, DeleteOutlined, ExclamationCircleOutlined } from "@ant-design/icons"; +import { DeleteOutlined, ExclamationCircleOutlined } from "@ant-design/icons"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; -import { Button, Card, Popconfirm, Tooltip } from "antd"; +import { Button, Card, Popconfirm } from "antd"; +import PropTypes from "prop-types"; import React from "react"; import styles from "./index.module.css"; import { apiKeyLocale } from "./locales"; -/** 取得方案名稱的輔助函式 */ -function getPlanName(id, text) { - switch (id) { - case 1: - return text.planBasic; - case 2: - return text.planProfessional; - case 3: - return text.planPayAsYouGo; - default: - return text.planUnknown; - } -} - /** * 單一 Token 卡片組件 */ export default function TokenCard({ item, - onCopyToken, onRevokeOrDelete, - onOpenDetail, maskToken, }) { const { i18n: { currentLocale }, } = useDocusaurusContext(); - const text = apiKeyLocale[currentLocale] || apiKeyLocale.en; - const isActive = item.is_active; - const planLabel = getPlanName(item.usage_plan_id, text); - // 按鈕文字 & Popconfirm 標題 - const buttonText = isActive ? text.revokeButton : text.deleteButton; - const popConfirmTitle = isActive + const { + name, + is_active, + expires_local, // 前端轉換後的當地時區時間 + jti, + __frontend_expired // 若前端判定該 token 已自然過期 + } = item; + + // 狀態 + // 1) 已過期 => 顯示「已過期」 + // 2) 已撤銷 => 顯示「已撤銷」(revoked) + // 3) 否則 => "有效" + let statusText = text.active; + if (!is_active) { + // 若 .__frontend_expired === true => 顯示「已過期」 + // 否則 => 「已撤銷」 + statusText = __frontend_expired ? (text.expired || "已過期") : text.revoked; + } + + // Popconfirm + const popConfirmTitle = is_active ? text.popconfirmRevokeTitle : text.popconfirmDeleteTitle; + const buttonText = is_active ? text.revokeButton : text.deleteButton; + return ( {/* 卡片 Header */}
-
-
{item.name || text.defaultTokenName}
-
{planLabel}
-
- - {/* Revoke / Delete 按鈕 */} +
{name || text.defaultTokenName}
} @@ -73,32 +69,31 @@ export default function TokenCard({ {/* 主要資訊 Rows */}
{text.tokenIdLabel} - - onCopyToken(item.jti)}> - {maskToken(item.jti)} - - - + {/* 已移除複製功能 */} + {maskToken(jti)}
{text.expiryLabel} - {item.expires_at || text.forever} + {expires_local || "-"}
{text.statusLabel} - {isActive ? text.active : text.revoked} + {statusText}
- - {/* 額外資訊 / Usage Detail 按鈕 */} - {isActive && onOpenDetail && ( -
- -
- )} ); } + +TokenCard.propTypes = { + item: PropTypes.shape({ + name: PropTypes.string, + is_active: PropTypes.bool.isRequired, + expires_local: PropTypes.string, + jti: PropTypes.string.isRequired, + __frontend_expired: PropTypes.bool, + }).isRequired, + onRevokeOrDelete: PropTypes.func.isRequired, + maskToken: PropTypes.func.isRequired, +}; diff --git a/src/components/Dashboard/ApiKey/UsageOverview.js b/src/components/Dashboard/ApiKey/UsageOverview.js index 9b2618bfc20..c15bfbdbc92 100644 --- a/src/components/Dashboard/ApiKey/UsageOverview.js +++ b/src/components/Dashboard/ApiKey/UsageOverview.js @@ -1,20 +1,22 @@ // src/components/Dashboard/ApiKey/UsageOverview.jsx import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { Card, Progress } from "antd"; +import PropTypes from "prop-types"; import React from "react"; import { apiKeyLocale } from "./locales"; +const cardStyle = { marginBottom: 24, borderRadius: 8 }; + export default function UsageOverview({ userUsage }) { const { i18n: { currentLocale }, } = useDocusaurusContext(); - const text = apiKeyLocale[currentLocale] || apiKeyLocale.en; if (!userUsage) { // 可依需求改為 skeleton 或 loading return ( - +

{text.loadingUsage}

); @@ -22,23 +24,31 @@ export default function UsageOverview({ userUsage }) { const { billing_type, used_this_hour, limit_per_hour, remaining } = userUsage; + // 當 limit_per_hour 為 0 時避免除以 0 錯誤 + const percent = + billing_type === "rate_limit" && limit_per_hour > 0 + ? Math.min(100, (used_this_hour / limit_per_hour) * 100) + : 0; + return ( - + {billing_type === "rate_limit" ? (
= limit_per_hour ? "exception" : "normal"} - width={80} + size={80} format={() => `${used_this_hour}/${limit_per_hour}`} />

{text.usageOverviewTitle}

- {text.usageThisHourLabel}{used_this_hour}/{limit_per_hour} + {text.usageThisHourLabel} + {used_this_hour}/{limit_per_hour}  •  - {text.remainingLabel}{remaining} + {text.remainingLabel} + {remaining}

@@ -47,13 +57,14 @@ export default function UsageOverview({ userUsage }) { `${used_this_hour}`} />

{text.usageOverviewTitle}

- {text.usageThisHourLabel}{used_this_hour} (Pay-As-You-Go) + {text.usageThisHourLabel} + {used_this_hour} (Pay-As-You-Go)

@@ -63,3 +74,12 @@ export default function UsageOverview({ userUsage }) {
); } + +UsageOverview.propTypes = { + userUsage: PropTypes.shape({ + billing_type: PropTypes.string.isRequired, + used_this_hour: PropTypes.number.isRequired, + limit_per_hour: PropTypes.number, + remaining: PropTypes.number, + }), +}; diff --git a/src/components/Dashboard/ApiKey/index.js b/src/components/Dashboard/ApiKey/index.js index e4a1e2eea68..7fb7d328b6d 100644 --- a/src/components/Dashboard/ApiKey/index.js +++ b/src/components/Dashboard/ApiKey/index.js @@ -1,32 +1,48 @@ -// src/components/Dashboard/ApiKey/index.js import { CopyOutlined, EyeInvisibleOutlined, EyeOutlined, - InfoCircleOutlined, PlusOutlined, } from "@ant-design/icons"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; -import { - Button, - Collapse, - Drawer, - List, - message -} from "antd"; +import { Button, List, message, Modal, Spin, Tabs } from "antd"; import React, { useCallback, useEffect, useState } from "react"; import { useAuth } from "../../../context/AuthContext"; -import styles from "./index.module.css"; -import { apiKeyLocale } from "./locales"; - +import ApiUsageExamples from "./ApiUsageExamples"; import CreateTokenModal from "./CreateTokenModal"; -import DocAlignerPanel from "./DocAlignerPanel"; import TokenCard from "./TokenCard"; import UsageOverview from "./UsageOverview"; +import styles from "./index.module.css"; +import { apiKeyLocale } from "./locales"; +// 後端路徑 +const PROFILE_URL = "https://api.docsaid.org/auth/me"; const API_BASE_URL = "https://api.docsaid.org/public/token"; +const { TabPane } = Tabs; + +/** 解析 JWT 中的 jti(供新建 Token 時使用) */ +function parseJti(jwtStr) { + try { + const parts = jwtStr.split("."); + if (parts.length !== 3) return null; + const payloadRaw = atob(parts[1]); + const payload = JSON.parse(payloadRaw); + return payload.jti; + } catch { + return null; + } +} + +/** 將 UTC 時間字串轉成本地時間 (不顯示「永久」字樣) */ +function formatToLocalTime(utcString) { + if (!utcString) return ""; // 若後端返回空 => 視為無期限,但避免誤導就留空 + const dt = new Date(utcString); + if (Number.isNaN(dt.getTime())) return utcString; // 解析失敗就原樣 + return dt.toLocaleString(); // 可自行換成 dayjs/moment +} + export default function DashboardApiKey() { const { token: userToken } = useAuth(); const { @@ -34,29 +50,50 @@ export default function DashboardApiKey() { } = useDocusaurusContext(); const text = apiKeyLocale[currentLocale] || apiKeyLocale.en; - // ===== Global states ===== + // ======================== + // State + // ======================== + const [userProfile, setUserProfile] = useState(null); + const [loadingProfile, setLoadingProfile] = useState(false); + const [apiKeys, setApiKeys] = useState([]); const [userUsage, setUserUsage] = useState(null); - const [loading, setLoading] = useState(false); - - // 控制 Token 明碼 - const [showTokenPlain, setShowTokenPlain] = useState(false); - // 建立 Token Modal + const [loading, setLoading] = useState(false); const [createModalVisible, setCreateModalVisible] = useState(false); - // Drawer: 詳細資訊 - const [drawerVisible, setDrawerVisible] = useState(false); - const [detailToken, setDetailToken] = useState(null); + // 顯示/隱藏「新建 Token 後」Modal,並保存本次創建的 Token 完整字串 + const [newTokenModalVisible, setNewTokenModalVisible] = useState(false); + const [latestCreatedToken, setLatestCreatedToken] = useState(""); + + // 是否顯示「明碼 jti」(可自行決定要不要保留此功能) + const [showTokenPlain, setShowTokenPlain] = useState(false); - // ===== DocAligner Panel: 公開 Token 測試 ===== - const [publicToken, setPublicToken] = useState(""); - const [usageData, setUsageData] = useState(null); - const [checkLoading, setCheckLoading] = useState(false); + // ======================== + // 抓取使用者檔案 /auth/me + // ======================== + const fetchUserProfile = useCallback(async () => { + if (!userToken) return; + setLoadingProfile(true); + try { + const res = await fetch(PROFILE_URL, { + headers: { Authorization: `Bearer ${userToken}` }, + }); + if (!res.ok) { + throw new Error(`Fetch user profile failed: ${res.status}`); + } + const data = await res.json(); + setUserProfile(data); + } catch (err) { + message.error(err.message); + } finally { + setLoadingProfile(false); + } + }, [userToken]); - // --------------------------------------- - // 1) 載入 Token 列表 - // --------------------------------------- + // ======================== + // 抓取 token 列表 /my-tokens + // ======================== const fetchTokens = useCallback(async () => { if (!userToken) return; setLoading(true); @@ -67,7 +104,21 @@ export default function DashboardApiKey() { if (!res.ok) { throw new Error(`Fetch tokens failed: ${res.status}`); } - const data = await res.json(); + let data = await res.json(); + + // 前端做「是否已自然過期」的檢查 + const now = new Date(); + data = data.map((tk) => { + if (tk.expires_at) { + const dt = new Date(tk.expires_at); + if (dt <= now) { + // 標記為過期 + return { ...tk, is_active: false, __frontend_expired: true }; + } + } + return tk; + }); + setApiKeys(data); } catch (err) { message.error(err.message); @@ -76,9 +127,9 @@ export default function DashboardApiKey() { } }, [userToken]); - // --------------------------------------- - // 2) 載入使用者整體用量 - // --------------------------------------- + // ======================== + // 抓取用量 /user-usage + // ======================== const fetchUserUsage = useCallback(async () => { if (!userToken) return; try { @@ -96,17 +147,28 @@ export default function DashboardApiKey() { } }, [userToken]); + // ======================== + // 頁面初始載入 + // ======================== useEffect(() => { + fetchUserProfile(); fetchTokens(); fetchUserUsage(); - }, [fetchTokens, fetchUserUsage]); + }, [fetchUserProfile, fetchTokens, fetchUserUsage]); - // --------------------------------------- - // 3) 建立 Token - // --------------------------------------- + // ======================== + // 申請新 Token + // ======================== const handleCreateToken = async (formValues) => { - const { usage_plan_id, isPermanent, expires_minutes, name } = formValues; - const finalExpires = isPermanent ? 999999 : expires_minutes; + if (!userProfile) return; + if (!userProfile.is_email_verified) { + message.error("尚未驗證電子郵件,無法申請 API Token。"); + return; + } + const { isLongTerm, expires_minutes, name } = formValues; + + // 長期 => 一年 (525600 分鐘) + const finalExpires = isLongTerm ? 525600 : expires_minutes; if (!userToken) { message.error(text.notLoggedIn); @@ -115,12 +177,16 @@ export default function DashboardApiKey() { setLoading(true); try { - const planParam = `usage_plan_id=${usage_plan_id}`; - const nameParam = name ? `&name=${encodeURIComponent(name)}` : ""; - const url = `${API_BASE_URL}/?${planParam}&expires_minutes=${finalExpires}${nameParam}`; - const res = await fetch(url, { + const res = await fetch(`${API_BASE_URL}/`, { method: "POST", - headers: { Authorization: `Bearer ${userToken}` }, + headers: { + Authorization: `Bearer ${userToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + expires_minutes: finalExpires, + name, + }), }); if (!res.ok) { const e = await res.json().catch(() => ({})); @@ -129,42 +195,45 @@ export default function DashboardApiKey() { const data = await res.json(); message.success(text.createTokenSuccessTitle); - // 也可用 Modal.success() - // -> 省略: 你可自行放 Token 的提示 - const jti = _parseJti(data.access_token) || `temp-${Date.now()}`; - const newItem = { + // 後端回傳 { access_token, expires_at, token_type...} + const newAccessToken = data.access_token; + const jti = parseJti(newAccessToken) || `temp-${Date.now()}`; + + // 新增到 apiKeys (但不重複存完整 token) + let newItem = { jti, - usage_plan_id: data.usage_plan_id || usage_plan_id, - expires_at: data.expires_at, is_active: true, + expires_at: data.expires_at, name: name || "", }; + // 檢查是否過期 + if (newItem.expires_at) { + const now = new Date(); + const dt = new Date(newItem.expires_at); + if (dt <= now) { + newItem.is_active = false; + newItem.__frontend_expired = true; + } + } setApiKeys((prev) => [newItem, ...prev]); + + // 顯示一次性的完整 Token + setLatestCreatedToken(newAccessToken); + setNewTokenModalVisible(true); + setCreateModalVisible(false); } catch (err) { - message.error(err.message); + message.error(err.message || "Create token failed"); } finally { setLoading(false); } }; - function _parseJti(jwtStr) { - try { - const parts = jwtStr.split("."); - if (parts.length !== 3) return null; - const payloadRaw = atob(parts[1]); - const payload = JSON.parse(payloadRaw); - return payload.jti; - } catch { - return null; - } - } - - // --------------------------------------- - // 4) 撤銷 / 刪除 Token - // --------------------------------------- + // ======================== + // Revoke / Remove + // ======================== const handleRevokeOrDelete = async (tokenItem) => { if (!userToken) return; setLoading(true); @@ -182,14 +251,10 @@ export default function DashboardApiKey() { const e = await res.json().catch(() => ({})); throw new Error(e.detail || `Operation failed: ${res.status}`); } - message.success(tokenItem.is_active ? text.tokenRevoked : text.tokenDeleted); + message.success( + tokenItem.is_active ? text.tokenRevoked : text.tokenDeleted + ); await fetchTokens(); - - // 如果 Drawer 正在顯示這個 Token,關閉 - if (detailToken && detailToken.jti === tokenItem.jti) { - setDrawerVisible(false); - setDetailToken(null); - } } catch (err) { message.error(err.message); } finally { @@ -197,85 +262,96 @@ export default function DashboardApiKey() { } }; - // --------------------------------------- - // 5) Drawer 打開 / 關閉 - // --------------------------------------- - const openDrawer = (tokenItem) => { - setDetailToken(tokenItem); - setDrawerVisible(true); - }; - const closeDrawer = () => { - setDrawerVisible(false); - setDetailToken(null); - }; - - // --------------------------------------- - // 6) 複製 Token - // --------------------------------------- - const copyToken = async (tokenId) => { + // ======================== + // 複製 Token (於新建後的 Modal) + // ======================== + const copyToken = async (tokenStr) => { + if (!tokenStr) { + message.error(text.copyFailure || "複製失敗"); + return; + } try { - await navigator.clipboard.writeText(tokenId); - message.success(text.copySuccess); + await navigator.clipboard.writeText(tokenStr); + // ★ 第 3 點:message.success => 提示「已複製」 + message.success("已複製"); } catch { - message.error(text.copyFailure); + message.error(text.copyFailure || "複製失敗"); } }; - // --------------------------------------- - // 7) 遮罩 Token - // --------------------------------------- + // 遮罩 jti const maskToken = (val) => { - if (!val) return ""; + if (!val) return "N/A"; if (showTokenPlain) return val; + if (val.length < 10) { + return val.slice(0, 2) + "****" + val.slice(-2); + } return val.slice(0, 6) + "****" + val.slice(-4); }; - // --------------------------------------- - // 8) DocAlignerPanel:查詢 usage - // --------------------------------------- - const handleCheckUsage = async () => { - if (!publicToken) { - message.warning("請先輸入公開 Token"); - return; - } - setCheckLoading(true); - try { - const res = await fetch(`${API_BASE_URL}/usage`, { - headers: { Authorization: `Bearer ${publicToken}` }, - }); - if (!res.ok) { - const e = await res.json().catch(() => ({})); - throw new Error(e.detail || "Failed to get usage"); - } - const data = await res.json(); - setUsageData(data); - message.success("Usage updated!"); - } catch (err) { - message.error(err.message); - setUsageData(null); - } finally { - setCheckLoading(false); + // 顯示計費方案 + function getPlanLabel(billingType) { + switch (billingType) { + case "rate_limit": + return "Basic (Free)"; + case "pay_per_use": + return "Pay-As-You-Go"; + default: + return "Unknown Plan"; } + } + const renderPlanBox = () => { + if (!userUsage) return null; + const planLabel = getPlanLabel(userUsage.billing_type); + return ( +
+ {text.currentPlanLabel}: {planLabel} +
+ ); }; + // ======================== + // 主體渲染 + // ======================== + if (loadingProfile && !userProfile) { + return ( +
+ +
+ ); + } + + if (userProfile && !userProfile.is_email_verified) { + return ( +
+

請先驗證電子郵件

+

+ 您尚未完成電子郵件驗證,無法使用 API Token 功能。
+ 請前往 MyInfo 頁面完成驗證。 +

+
+ ); + } + return (
- {/* 頁面標題 */} + {/* Header */}

{text.headerTitle}

{text.headerDescription}

- {/* 用量概覽 */} + {/* 顯示方案 & 用量 */} + {renderPlanBox()} - {/* 操作按鈕區 */} + {/* 操作按鈕 */}
@@ -285,45 +361,40 @@ export default function DashboardApiKey() {
- {/* Collapse: Token 列表 + DocAligner */} - - - - {text.collapseHeader} -
- } - > + + + {/* 第4點: 調整排列 / 間距 */} item.jti} - renderItem={(item) => ( - - )} - /> - - - - { + // 將 expires_at (UTC) => 本地時間字串 + const localExpires = item.expires_at + ? formatToLocalTime(item.expires_at) + : ""; // 避免顯示 "forever" 改成空 + + return ( + + + + ); + }} /> - - + + + + + {/* 建立 Token 的 Modal */} - {/* Drawer - Token 詳細資訊 */} - setNewTokenModalVisible(false)} + footer={null} + destroyOnClose > - {detailToken && ( - <> -

- {text.drawerTokenIdLabel}{" "} - copyToken(detailToken.jti)}> - {maskToken(detailToken.jti)} - - -

-

- {text.drawerExpiryLabel}{" "} - {detailToken.expires_at || text.forever} -

-

- {text.drawerStatusLabel}{" "} - {detailToken.is_active ? text.active : text.revoked} -

- - )} -
+

+ {text.newTokenModalDesc || "請複製並保存,關閉後無法再次查看。"} +

+
+ {latestCreatedToken} +
+ + +
); } diff --git a/src/components/Dashboard/ApiKey/index.module.css b/src/components/Dashboard/ApiKey/index.module.css index 3da1538da06..73bd568e146 100644 --- a/src/components/Dashboard/ApiKey/index.module.css +++ b/src/components/Dashboard/ApiKey/index.module.css @@ -5,38 +5,43 @@ max-width: 900px; margin: 0 auto; padding: 24px; - text-align: left; /* 左上角對齊 */ + background-color: #fafafa; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); } /* 頁面標題 */ .header { - margin-bottom: 16px; + margin-bottom: 24px; text-align: left; } .header h2 { - font-size: 1.6rem; - margin: 0 0 4px 0; + font-size: 2rem; + margin: 0 0 8px 0; + color: #333; } .header p { - color: #777; + font-size: 1rem; + color: #555; margin: 0; } /* 操作按鈕列 */ .actions { - margin-bottom: 16px; + margin-bottom: 24px; display: flex; - gap: 8px; + gap: 12px; } /* 用於顯示建立完成後的 Token */ .tokenBox { - background: #f5f5f5; - padding: 8px; + background: #fff; + padding: 12px; word-break: break-all; font-family: monospace; - border: 1px solid #ccc; + border: 1px solid #ddd; border-radius: 4px; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05); } /* Collapse 外框 */ @@ -44,6 +49,7 @@ background-color: #fff; border: 1px solid #e8e8e8; border-radius: 8px; + margin-bottom: 24px; } /* List.Item */ @@ -55,13 +61,15 @@ /* Token 卡片 */ .tokenCard { width: 100%; + background-color: #fff; border-radius: 8px; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); - transition: box-shadow 0.2s ease; + padding: 16px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + transition: transform 0.2s ease, box-shadow 0.2s ease; } - .tokenCard:hover { - box-shadow: 0 2px 6px rgba(0,0,0,0.15); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); } /* 卡片頂部區域 */ @@ -69,29 +77,32 @@ display: flex; align-items: center; justify-content: space-between; - margin-bottom: 8px; + margin-bottom: 12px; } /* Token 名稱 */ .tokenName { font-weight: 600; - font-size: 1rem; + font-size: 1.1rem; margin-bottom: 4px; + color: #333; } /* 方案標籤 */ .planLabel { - font-size: 0.85rem; - color: #666; + font-size: 0.9rem; + color: #888; } /* 一般欄位 */ .tokenRow { - margin: 4px 0; - line-height: 1.6; + margin: 6px 0; + line-height: 1.8; + font-size: 0.95rem; + color: #444; } .label { font-weight: 500; - color: #555; + color: #666; margin-right: 4px; } diff --git a/src/components/Dashboard/ApiKey/locales.js b/src/components/Dashboard/ApiKey/locales.js index d4d09aeb121..966c885a513 100644 --- a/src/components/Dashboard/ApiKey/locales.js +++ b/src/components/Dashboard/ApiKey/locales.js @@ -39,7 +39,7 @@ export const apiKeyLocale = { drawerInfo: "Token Info", drawerTokenIdLabel: "Token (ID, 遮罩): ", drawerExpiryLabel: "到期時間:", - drawerStatusLabel: "狀態:", + drawerStatusLabel: "狀態:", tabUsageInfo: "用量資訊", usageInfoInstruction: "按下「查詢用量」按鈕來顯示最新使用量。", checkUsageButton: "查詢用量", @@ -57,6 +57,16 @@ export const apiKeyLocale = { remainingLabel: "剩餘:", payPerUseInfo: "Pay-Per-Use, 無上限", missingFullToken: "無法查用量,缺少完整 Token", + apiUsageExampleTitle: "API 使用範例", + apiUsageYourPublicToken: "你的公開 Token", + apiUsageInputPlaceholder: "輸入你的公開 Token", + apiUsageCheckUsageButton: "查詢使用量", + apiUsageBillingType: "計費模式", + apiUsageUsedThisHour: "本小時已用次數", + apiUsageRemaining: "剩餘次數", + currentPlanLabel: "目前方案", + upgradeNotImplemented: "尚未開放", + upgradeButtonLabel: "升級", }, en: { planBasic: "Basic (Free)", @@ -77,8 +87,7 @@ export const apiKeyLocale = { revoked: "Revoked", detailUsageButton: "Details / Usage", headerTitle: "My API Keys", - headerDescription: - "Manage, view, revoke, and delete your public tokens", + headerDescription: "Manage, view, revoke, and delete your public tokens", createTokenButton: "Create New Token", toggleShowTokens: "Show All Tokens", toggleHideTokens: "Hide All Tokens", @@ -100,26 +109,32 @@ export const apiKeyLocale = { drawerExpiryLabel: "Expiry:", drawerStatusLabel: "Status:", tabUsageInfo: "Usage Info", - usageInfoInstruction: - "Click the 'Check Usage' button to display the latest usage.", + usageInfoInstruction: "Click the 'Check Usage' button to display the latest usage.", checkUsageButton: "Check Usage", - usageNote: - "※ If the token has been revoked (or the full JWT is not saved), usage cannot be checked.", + usageNote: "※ If the token has been revoked (or the full JWT is not saved), usage cannot be checked.", tabOthers: "Others", - othersContent: - "You can place additional information, instructions, or usage examples here.", + othersContent: "You can place additional information, instructions, or usage examples here.", notLoggedIn: "Not logged in, unable to create token", createTokenSuccessTitle: "Token Created!", createTokenSuccessContent: "Please securely save the following token (full JWT); it will not be shown again:", - parseJtiWarning: - "Unable to parse jti; usage cannot be checked later", + parseJtiWarning: "Unable to parse jti; usage cannot be checked later", copySuccess: "Copied", copyFailure: "Copy failed", usageThisHourLabel: "Usage this hour:", remainingLabel: "Remaining:", payPerUseInfo: "Pay-Per-Use, No Limit", missingFullToken: "Unable to check usage, full token is missing", + apiUsageExampleTitle: "API Usage Examples", + apiUsageYourPublicToken: "Your Public Token", + apiUsageInputPlaceholder: "Enter your Public Token", + apiUsageCheckUsageButton: "Check Usage", + apiUsageBillingType: "Billing Type", + apiUsageUsedThisHour: "Usage This Hour", + apiUsageRemaining: "Remaining", + currentPlanLabel: "Current Plan", + upgradeNotImplemented: "Not implemented.", + upgradeButtonLabel: "Upgrade Now", }, ja: { planBasic: "ベーシック (無料)", @@ -160,7 +175,7 @@ export const apiKeyLocale = { drawerInfo: "トークン情報", drawerTokenIdLabel: "トークン (ID, マスク済み): ", drawerExpiryLabel: "有効期限:", - drawerStatusLabel: "状態:", + drawerStatusLabel: "状態:", tabUsageInfo: "使用状況情報", usageInfoInstruction: "「使用状況確認」ボタンをクリックして最新の使用状況を表示します。", @@ -181,5 +196,16 @@ export const apiKeyLocale = { remainingLabel: "残り:", payPerUseInfo: "従量課金, 上限なし", missingFullToken: "使用状況を確認できません。完全なトークンがありません", + // 統一 API 使用範例相關鍵值,全部改為 apiUsage 前綴 + apiUsageExampleTitle: "API利用例", + apiUsageYourPublicToken: "あなたの公開トークン", + apiUsageInputPlaceholder: "あなたの公開トークンを入力してください", + apiUsageCheckUsageButton: "使用状況確認", + apiUsageBillingType: "請求タイプ", + apiUsageUsedThisHour: "今時間の使用量", + apiUsageRemaining: "残り", + currentPlanLabel: "Current Plan", + upgradeNotImplemented: "Not implemented.", + upgradeButtonLabel: "Upgrade Now", }, }; From ff9bd1492e9164025bea6e1817bf0baf50a298e4 Mon Sep 17 00:00:00 2001 From: zephyr-sh Date: Sat, 15 Mar 2025 10:07:59 +0800 Subject: [PATCH 18/25] [F] Fixed post error --- src/components/Dashboard/ApiKey/index.js | 38 ++++++++++++++---------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/components/Dashboard/ApiKey/index.js b/src/components/Dashboard/ApiKey/index.js index 7fb7d328b6d..48af4d924a0 100644 --- a/src/components/Dashboard/ApiKey/index.js +++ b/src/components/Dashboard/ApiKey/index.js @@ -45,9 +45,7 @@ function formatToLocalTime(utcString) { export default function DashboardApiKey() { const { token: userToken } = useAuth(); - const { - i18n: { currentLocale }, - } = useDocusaurusContext(); + const { i18n: { currentLocale } } = useDocusaurusContext(); const text = apiKeyLocale[currentLocale] || apiKeyLocale.en; // ======================== @@ -167,7 +165,7 @@ export default function DashboardApiKey() { } const { isLongTerm, expires_minutes, name } = formValues; - // 長期 => 一年 (525600 分鐘) + // 長期使用設定為一年 (525600 分鐘) const finalExpires = isLongTerm ? 525600 : expires_minutes; if (!userToken) { @@ -177,17 +175,21 @@ export default function DashboardApiKey() { setLoading(true); try { - const res = await fetch(`${API_BASE_URL}/`, { + // 將 expires_minutes 與 name 以 query string 方式傳遞 + const params = new URLSearchParams({ + expires_minutes: finalExpires, + name: name || "" + }); + + const res = await fetch(`${API_BASE_URL}/?${params.toString()}`, { method: "POST", headers: { Authorization: `Bearer ${userToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - expires_minutes: finalExpires, - name, - }), + "Content-Type": "application/json" + } + // 參數已在 URL 中傳遞,故不需要傳 body 資料 }); + if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.detail || `Create token failed: ${res.status}`); @@ -196,18 +198,19 @@ export default function DashboardApiKey() { message.success(text.createTokenSuccessTitle); - // 後端回傳 { access_token, expires_at, token_type...} + // 後端回傳 { access_token, expires_at, token_type... } const newAccessToken = data.access_token; const jti = parseJti(newAccessToken) || `temp-${Date.now()}`; - // 新增到 apiKeys (但不重複存完整 token) + // 新增到 apiKeys (只存必要資訊) let newItem = { jti, is_active: true, expires_at: data.expires_at, - name: name || "", + name: name || "" }; - // 檢查是否過期 + + // 檢查是否已過期 if (newItem.expires_at) { const now = new Date(); const dt = new Date(newItem.expires_at); @@ -219,10 +222,13 @@ export default function DashboardApiKey() { setApiKeys((prev) => [newItem, ...prev]); + setTimeout(async () => { + await fetchTokens(); + }, 1000); + // 顯示一次性的完整 Token setLatestCreatedToken(newAccessToken); setNewTokenModalVisible(true); - setCreateModalVisible(false); } catch (err) { message.error(err.message || "Create token failed"); From b741714f69cd7cc787f48cd10c57a4db4b2a4986 Mon Sep 17 00:00:00 2001 From: zephyr-sh Date: Sat, 15 Mar 2025 13:13:22 +0800 Subject: [PATCH 19/25] [C] Update i18n settings --- .../Dashboard/ApiKey/ApiUsageExamples.js | 15 +- .../Dashboard/ApiKey/CreateTokenModal.js | 48 +++- .../Dashboard/ApiKey/DocAlignerPanel.js | 92 ------- src/components/Dashboard/ApiKey/TokenCard.js | 64 ++++- .../Dashboard/ApiKey/UsageOverview.js | 22 +- src/components/Dashboard/ApiKey/index.js | 250 +++++++++++------- src/components/Dashboard/ApiKey/locales.js | 211 --------------- 7 files changed, 287 insertions(+), 415 deletions(-) delete mode 100644 src/components/Dashboard/ApiKey/DocAlignerPanel.js delete mode 100644 src/components/Dashboard/ApiKey/locales.js diff --git a/src/components/Dashboard/ApiKey/ApiUsageExamples.js b/src/components/Dashboard/ApiKey/ApiUsageExamples.js index b2e96299321..5a122694d70 100644 --- a/src/components/Dashboard/ApiKey/ApiUsageExamples.js +++ b/src/components/Dashboard/ApiKey/ApiUsageExamples.js @@ -2,10 +2,21 @@ import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { Card, Tabs } from "antd"; import React from "react"; -import { apiKeyLocale } from "./locales"; const { TabPane } = Tabs; +const apiKeyLocale = { + "zh-hant": { + apiUsageExampleTitle: "API 使用範例", + }, + en: { + apiUsageExampleTitle: "API Usage Examples", + }, + ja: { + apiUsageExampleTitle: "API利用例", + }, +}; + export default function ApiUsageExamples() { const { i18n: { currentLocale }, @@ -160,7 +171,7 @@ puts response.body`, }; return ( - + diff --git a/src/components/Dashboard/ApiKey/CreateTokenModal.js b/src/components/Dashboard/ApiKey/CreateTokenModal.js index 86a76fbd4c2..b9e58cb01fc 100644 --- a/src/components/Dashboard/ApiKey/CreateTokenModal.js +++ b/src/components/Dashboard/ApiKey/CreateTokenModal.js @@ -1,8 +1,47 @@ +// src/components/Dashboard/ApiKey/CreateTokenModal.js import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { Button, Checkbox, Form, Input, InputNumber, Modal, Space } from "antd"; import PropTypes from "prop-types"; import React, { useEffect } from "react"; -import { apiKeyLocale } from "./locales"; + +const apiKeyLocale = { + "zh-hant": { + createModalTitle: "建立新的公開 Token", + formTokenNameLabel: "Token 名稱", + formTokenNameTooltip: "可給 Token 一個易識別的名稱", + formTokenNamePlaceholder: "如:My DocAligner Key", + formIsLongTermLabel: "申請一年效期", + formIsLongTermCheckbox: "勾選後自動設定 1 年效期 (525600 分鐘)", + formExpiryLabel: "有效期 (分鐘)", + formExpiryValidationMessage: "請輸入有效期", + cancelButton: "取消", + createButton: "建立", + }, + en: { + createModalTitle: "Create New Public Token", + formTokenNameLabel: "Token Name", + formTokenNameTooltip: "Give the token an easily recognizable name", + formTokenNamePlaceholder: "e.g., My DocAligner Key", + formIsLongTermLabel: "Apply for a one-year term", + formIsLongTermCheckbox: "Check to automatically set a 1-year term (525600 minutes)", + formExpiryLabel: "Expiry (minutes)", + formExpiryValidationMessage: "Please enter the expiration time", + cancelButton: "Cancel", + createButton: "Create", + }, + ja: { + createModalTitle: "新しい公開トークンを作成", + formTokenNameLabel: "トークン名", + formTokenNameTooltip: "トークンにわかりやすい名前を付けることができます", + formTokenNamePlaceholder: "例:My DocAligner Key", + formIsLongTermLabel: "一年間の有効期限を申請", + formIsLongTermCheckbox: "チェックすると、自動的に1年間の有効期限(525600分)を設定します", + formExpiryLabel: "有効期限(分)", + formExpiryValidationMessage: "有効期限を入力してください", + cancelButton: "キャンセル", + createButton: "作成", + }, +}; export default function CreateTokenModal({ visible, @@ -11,7 +50,7 @@ export default function CreateTokenModal({ loading = false, defaultValues = { expires_minutes: 60, - isLongTerm: false, // 改用 isLongTerm + isLongTerm: false, }, }) { const [form] = Form.useForm(); @@ -47,13 +86,12 @@ export default function CreateTokenModal({ - {/* isLongTerm: 勾選即用 1年 */} - 勾選後自動設定 1 年效期 (525600 分鐘) + {text.formIsLongTermCheckbox} " }" \\ - -F "file=@/path/to/your/document.jpg"`; - - const docalignerCodePython = `import requests - -url = "https://api.docsaid.org/docaligner-public-predict" -headers = { - "Authorization": "Bearer ${publicToken || "<${text.docAlignerYourPublicToken}>" }" -} -files = { - "file": open("/path/to/your/document.jpg", "rb") -} -response = requests.post(url, headers=headers, files=files) -print(response.json())`; - - return ( - - - setPublicToken(e.target.value)} - style={{ width: 400 }} - /> - - - - {usageData && ( - - - {usageData.billing_type} - - - {usageData.used_this_hour ?? "-"} - - {usageData.remaining !== undefined && ( - - {usageData.remaining} - - )} - - )} - - - - {docalignerCodeCurl} - - ), - }, - { - key: "python", - label: "Python", - children: ( -
-                  {docalignerCodePython}
-                
- ), - }, - ]} - /> -
-
- ); -} diff --git a/src/components/Dashboard/ApiKey/TokenCard.js b/src/components/Dashboard/ApiKey/TokenCard.js index 4eb099bba10..53ff07c7bc4 100644 --- a/src/components/Dashboard/ApiKey/TokenCard.js +++ b/src/components/Dashboard/ApiKey/TokenCard.js @@ -1,10 +1,52 @@ +// src/components/Dashboard/ApiKey/TokenCard.js import { DeleteOutlined, ExclamationCircleOutlined } from "@ant-design/icons"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { Button, Card, Popconfirm } from "antd"; import PropTypes from "prop-types"; import React from "react"; import styles from "./index.module.css"; -import { apiKeyLocale } from "./locales"; + +const apiKeyLocale = { + "zh-hant": { + active: "有效", + expired: "已過期", + revoked: "已撤銷", + popconfirmRevokeTitle: "確定撤銷這個 Token 嗎?", + popconfirmDeleteTitle: "確定刪除這個 Token 嗎?", + revokeButton: "撤銷", + deleteButton: "刪除", + defaultTokenName: "My Token", + tokenIdLabel: "Token (ID):", + expiryLabel: "到期時間:", + statusLabel: "狀態:", + }, + en: { + active: "Active", + expired: "Expired", + revoked: "Revoked", + popconfirmRevokeTitle: "Are you sure you want to revoke this token?", + popconfirmDeleteTitle: "Are you sure you want to delete this token?", + revokeButton: "Revoke", + deleteButton: "Delete", + defaultTokenName: "My Token", + tokenIdLabel: "Token (ID):", + expiryLabel: "Expiry:", + statusLabel: "Status:", + }, + ja: { + active: "有効", + expired: "期限切れ", + revoked: "無効", + popconfirmRevokeTitle: "このトークンを取り消してもよろしいですか?", + popconfirmDeleteTitle: "このトークンを削除してもよろしいですか?", + revokeButton: "取り消し", + deleteButton: "削除", + defaultTokenName: "My Token", + tokenIdLabel: "トークン (ID):", + expiryLabel: "有効期限:", + statusLabel: "状態:", + }, +}; /** * 單一 Token 卡片組件 @@ -22,34 +64,33 @@ export default function TokenCard({ const { name, is_active, - expires_local, // 前端轉換後的當地時區時間 + expires_local, // 前端轉換後的當地時區時間 jti, - __frontend_expired // 若前端判定該 token 已自然過期 + __frontend_expired, // 若前端判定該 token 已自然過期 } = item; // 狀態 - // 1) 已過期 => 顯示「已過期」 - // 2) 已撤銷 => 顯示「已撤銷」(revoked) - // 3) 否則 => "有效" + // 1) 已過期 => 顯示「已過期」 + // 2) 已撤銷 => 顯示「已撤銷」 + // 3) 否則 => 顯示「有效」 let statusText = text.active; if (!is_active) { - // 若 .__frontend_expired === true => 顯示「已過期」 - // 否則 => 「已撤銷」 - statusText = __frontend_expired ? (text.expired || "已過期") : text.revoked; + statusText = __frontend_expired ? text.expired : text.revoked; } // Popconfirm const popConfirmTitle = is_active ? text.popconfirmRevokeTitle : text.popconfirmDeleteTitle; - const buttonText = is_active ? text.revokeButton : text.deleteButton; return ( {/* 卡片 Header */}
-
{name || text.defaultTokenName}
+
+ {name || text.defaultTokenName} +
} @@ -69,7 +110,6 @@ export default function TokenCard({ {/* 主要資訊 Rows */}
{text.tokenIdLabel} - {/* 已移除複製功能 */} {maskToken(jti)}
diff --git a/src/components/Dashboard/ApiKey/UsageOverview.js b/src/components/Dashboard/ApiKey/UsageOverview.js index c15bfbdbc92..01c86264f3e 100644 --- a/src/components/Dashboard/ApiKey/UsageOverview.js +++ b/src/components/Dashboard/ApiKey/UsageOverview.js @@ -3,7 +3,27 @@ import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { Card, Progress } from "antd"; import PropTypes from "prop-types"; import React from "react"; -import { apiKeyLocale } from "./locales"; + +const apiKeyLocale = { + "zh-hant": { + loadingUsage: "載入使用情況...", + usageOverviewTitle: "用量概覽", + usageThisHourLabel: "本小時用量:", + remainingLabel: "剩餘:", + }, + en: { + loadingUsage: "Loading usage...", + usageOverviewTitle: "Usage Overview", + usageThisHourLabel: "Usage this hour:", + remainingLabel: "Remaining:", + }, + ja: { + loadingUsage: "使用状況を読み込み中...", + usageOverviewTitle: "使用状況概観", + usageThisHourLabel: "今時間の使用量:", + remainingLabel: "残り:", + }, +}; const cardStyle = { marginBottom: 24, borderRadius: 8 }; diff --git a/src/components/Dashboard/ApiKey/index.js b/src/components/Dashboard/ApiKey/index.js index 48af4d924a0..3f7926d0f3c 100644 --- a/src/components/Dashboard/ApiKey/index.js +++ b/src/components/Dashboard/ApiKey/index.js @@ -14,14 +14,114 @@ import CreateTokenModal from "./CreateTokenModal"; import TokenCard from "./TokenCard"; import UsageOverview from "./UsageOverview"; import styles from "./index.module.css"; -import { apiKeyLocale } from "./locales"; - -// 後端路徑 -const PROFILE_URL = "https://api.docsaid.org/auth/me"; -const API_BASE_URL = "https://api.docsaid.org/public/token"; const { TabPane } = Tabs; +const apiKeyLocale = { + "zh-hant": { + headerTitle: "我的 API Keys", + headerDescription: "管理、檢視、撤銷與刪除你的公開 Token", + currentPlanLabel: "目前方案", + toggleShowTokens: "顯示全部 Token", + toggleHideTokens: "隱藏全部 Token", + createTokenButton: "建立新 Token", + collapseHeader: "我的 Token 列表", + apiUsageExampleTitle: "API 使用範例", + newTokenModalTitle: "以下是您的新 Token", + newTokenModalDesc: "請複製並保存,關閉後無法再次查看。", + copyTokenButton: "複製 Token", + closeButton: "關閉", + notLoggedIn: "尚未登入,無法申請 Token", + tokenRevoked: "Token 已撤銷", + tokenDeleted: "Token 已刪除", + createTokenSuccessTitle: "Token 建立成功", + copyFailure: "複製失敗", + copySuccess: "已複製", + emailNotVerified: "尚未驗證電子郵件,無法申請 API Token。", + loadingProfile: "載入個人資料...", + pleaseVerifyEmailTitle: "請先驗證電子郵件", + pleaseVerifyEmailDesc: "您尚未完成電子郵件驗證,無法使用 API Token 功能。請前往 {0} 頁面完成驗證。", + myInfoPage: "我的資訊", + fetchUserProfileFailed: "載入個人資料失敗: {0}", + fetchTokensFailed: "取得 Token 列表失敗: {0}", + fetchUserUsageFailed: "取得用量失敗: {0}", + createTokenFailed: "建立 Token 失敗: {0}", + operationFailed: "操作失敗: {0}", + notAvailable: "N/A", + basicPlan: "基本 (免費)", + payPerUsePlan: "隨用隨付", + unknownPlan: "未知方案", + }, + en: { + headerTitle: "My API Keys", + headerDescription: "Manage, view, revoke, and delete your public tokens", + currentPlanLabel: "Current Plan", + toggleShowTokens: "Show All Tokens", + toggleHideTokens: "Hide All Tokens", + createTokenButton: "Create New Token", + collapseHeader: "My Token List", + apiUsageExampleTitle: "API Usage Examples", + newTokenModalTitle: "Your New Token", + newTokenModalDesc: "Please copy and save it. It will not be shown again after closing.", + copyTokenButton: "Copy Token", + closeButton: "Close", + notLoggedIn: "Not logged in, unable to create Token", + tokenRevoked: "Token Revoked", + tokenDeleted: "Token Deleted", + createTokenSuccessTitle: "Token Created Successfully", + copyFailure: "Copy failed", + copySuccess: "Copied", + emailNotVerified: "Email not verified, unable to create API Token.", + loadingProfile: "Loading Profile...", + pleaseVerifyEmailTitle: "Please verify your email", + pleaseVerifyEmailDesc: "You have not verified your email, and cannot use the API Token feature. Please go to the {0} page to complete verification.", + myInfoPage: "My Info", + fetchUserProfileFailed: "Failed to load profile: {0}", + fetchTokensFailed: "Failed to fetch tokens: {0}", + fetchUserUsageFailed: "Failed to fetch usage: {0}", + createTokenFailed: "Failed to create token: {0}", + operationFailed: "Operation failed: {0}", + notAvailable: "N/A", + basicPlan: "Basic (Free)", + payPerUsePlan: "Pay-As-You-Go", + unknownPlan: "Unknown Plan", + }, + ja: { + headerTitle: "マイAPIキー", + headerDescription: "公開トークンの管理、確認、取り消し、削除", + currentPlanLabel: "Current Plan", + toggleShowTokens: "全トークンを表示", + toggleHideTokens: "全トークンを隠す", + createTokenButton: "新規トークン作成", + collapseHeader: "マイトークン一覧", + apiUsageExampleTitle: "API利用例", + newTokenModalTitle: "あなたの新しいトークン", + newTokenModalDesc: "コピーして保存してください。閉じると再表示されません。", + copyTokenButton: "トークンをコピー", + closeButton: "閉じる", + notLoggedIn: "未ログインのため、Token を作成できません", + tokenRevoked: "Token は取り消されました", + tokenDeleted: "Token は削除されました", + createTokenSuccessTitle: "Token 作成成功", + copyFailure: "コピー失敗", + copySuccess: "コピーしました", + emailNotVerified: "メールが認証されていないため、API Token を作成できません。", + loadingProfile: "プロファイルを読み込み中...", + pleaseVerifyEmailTitle: "メール認証をしてください", + pleaseVerifyEmailDesc: "メール認証が完了していないため、API Token 機能を利用できません。{0} ページに進んで認証を完了してください。", + myInfoPage: "マイ情報", + fetchUserProfileFailed: "プロファイルの読み込みに失敗しました: {0}", + fetchTokensFailed: "トークン一覧の取得に失敗しました: {0}", + fetchUserUsageFailed: "使用状況の取得に失敗しました: {0}", + createTokenFailed: "トークンの作成に失敗しました: {0}", + operationFailed: "操作に失敗しました: {0}", + notAvailable: "N/A", + basicPlan: "ベーシック (無料)", + payPerUsePlan: "従量課金", + unknownPlan: "不明なプラン", + }, +}; + /** 解析 JWT 中的 jti(供新建 Token 時使用) */ function parseJti(jwtStr) { try { @@ -35,17 +135,19 @@ function parseJti(jwtStr) { } } -/** 將 UTC 時間字串轉成本地時間 (不顯示「永久」字樣) */ +/** 將 UTC 時間字串轉成本地時間 */ function formatToLocalTime(utcString) { - if (!utcString) return ""; // 若後端返回空 => 視為無期限,但避免誤導就留空 + if (!utcString) return ""; const dt = new Date(utcString); - if (Number.isNaN(dt.getTime())) return utcString; // 解析失敗就原樣 - return dt.toLocaleString(); // 可自行換成 dayjs/moment + if (Number.isNaN(dt.getTime())) return utcString; + return dt.toLocaleString(); } export default function DashboardApiKey() { const { token: userToken } = useAuth(); - const { i18n: { currentLocale } } = useDocusaurusContext(); + const { + i18n: { currentLocale }, + } = useDocusaurusContext(); const text = apiKeyLocale[currentLocale] || apiKeyLocale.en; // ======================== @@ -53,18 +155,12 @@ export default function DashboardApiKey() { // ======================== const [userProfile, setUserProfile] = useState(null); const [loadingProfile, setLoadingProfile] = useState(false); - const [apiKeys, setApiKeys] = useState([]); const [userUsage, setUserUsage] = useState(null); - const [loading, setLoading] = useState(false); const [createModalVisible, setCreateModalVisible] = useState(false); - - // 顯示/隱藏「新建 Token 後」Modal,並保存本次創建的 Token 完整字串 const [newTokenModalVisible, setNewTokenModalVisible] = useState(false); const [latestCreatedToken, setLatestCreatedToken] = useState(""); - - // 是否顯示「明碼 jti」(可自行決定要不要保留此功能) const [showTokenPlain, setShowTokenPlain] = useState(false); // ======================== @@ -74,11 +170,11 @@ export default function DashboardApiKey() { if (!userToken) return; setLoadingProfile(true); try { - const res = await fetch(PROFILE_URL, { + const res = await fetch("https://api.docsaid.org/auth/me", { headers: { Authorization: `Bearer ${userToken}` }, }); if (!res.ok) { - throw new Error(`Fetch user profile failed: ${res.status}`); + throw new Error(text.fetchUserProfileFailed.replace("{0}", res.status)); } const data = await res.json(); setUserProfile(data); @@ -87,7 +183,7 @@ export default function DashboardApiKey() { } finally { setLoadingProfile(false); } - }, [userToken]); + }, [userToken, text]); // ======================== // 抓取 token 列表 /my-tokens @@ -96,34 +192,30 @@ export default function DashboardApiKey() { if (!userToken) return; setLoading(true); try { - const res = await fetch(`${API_BASE_URL}/my-tokens`, { + const res = await fetch("https://api.docsaid.org/public/token/my-tokens", { headers: { Authorization: `Bearer ${userToken}` }, }); if (!res.ok) { - throw new Error(`Fetch tokens failed: ${res.status}`); + throw new Error(text.fetchTokensFailed.replace("{0}", res.status)); } let data = await res.json(); - - // 前端做「是否已自然過期」的檢查 const now = new Date(); data = data.map((tk) => { if (tk.expires_at) { const dt = new Date(tk.expires_at); if (dt <= now) { - // 標記為過期 return { ...tk, is_active: false, __frontend_expired: true }; } } return tk; }); - setApiKeys(data); } catch (err) { message.error(err.message); } finally { setLoading(false); } - }, [userToken]); + }, [userToken, text]); // ======================== // 抓取用量 /user-usage @@ -131,19 +223,19 @@ export default function DashboardApiKey() { const fetchUserUsage = useCallback(async () => { if (!userToken) return; try { - const res = await fetch(`${API_BASE_URL}/user-usage`, { + const res = await fetch("https://api.docsaid.org/public/token/user-usage", { headers: { Authorization: `Bearer ${userToken}` }, }); if (!res.ok) { const e = await res.json().catch(() => ({})); - throw new Error(e.detail || `Fetch user usage error: ${res.status}`); + throw new Error(e.detail || text.fetchUserUsageFailed.replace("{0}", res.status)); } const usage = await res.json(); setUserUsage(usage); } catch (err) { console.error(err); } - }, [userToken]); + }, [userToken, text]); // ======================== // 頁面初始載入 @@ -160,12 +252,10 @@ export default function DashboardApiKey() { const handleCreateToken = async (formValues) => { if (!userProfile) return; if (!userProfile.is_email_verified) { - message.error("尚未驗證電子郵件,無法申請 API Token。"); + message.error(text.emailNotVerified); return; } const { isLongTerm, expires_minutes, name } = formValues; - - // 長期使用設定為一年 (525600 分鐘) const finalExpires = isLongTerm ? 525600 : expires_minutes; if (!userToken) { @@ -175,42 +265,37 @@ export default function DashboardApiKey() { setLoading(true); try { - // 將 expires_minutes 與 name 以 query string 方式傳遞 const params = new URLSearchParams({ expires_minutes: finalExpires, - name: name || "" + name: name || "", }); - - const res = await fetch(`${API_BASE_URL}/?${params.toString()}`, { - method: "POST", - headers: { - Authorization: `Bearer ${userToken}`, - "Content-Type": "application/json" + const res = await fetch( + `https://api.docsaid.org/public/token/?${params.toString()}`, + { + method: "POST", + headers: { + Authorization: `Bearer ${userToken}`, + "Content-Type": "application/json", + }, } - // 參數已在 URL 中傳遞,故不需要傳 body 資料 - }); - + ); if (!res.ok) { const e = await res.json().catch(() => ({})); - throw new Error(e.detail || `Create token failed: ${res.status}`); + throw new Error(e.detail || text.createTokenFailed.replace("{0}", res.status)); } const data = await res.json(); - message.success(text.createTokenSuccessTitle); - // 後端回傳 { access_token, expires_at, token_type... } const newAccessToken = data.access_token; const jti = parseJti(newAccessToken) || `temp-${Date.now()}`; - // 新增到 apiKeys (只存必要資訊) let newItem = { jti, is_active: true, expires_at: data.expires_at, - name: name || "" + name: name || "", }; - // 檢查是否已過期 if (newItem.expires_at) { const now = new Date(); const dt = new Date(newItem.expires_at); @@ -221,31 +306,29 @@ export default function DashboardApiKey() { } setApiKeys((prev) => [newItem, ...prev]); - setTimeout(async () => { await fetchTokens(); }, 1000); - // 顯示一次性的完整 Token setLatestCreatedToken(newAccessToken); setNewTokenModalVisible(true); setCreateModalVisible(false); } catch (err) { - message.error(err.message || "Create token failed"); + message.error(err.message || text.createTokenFailed.replace("{0}", "")); } finally { setLoading(false); } }; // ======================== - // Revoke / Remove + // Revoke / Remove Token // ======================== const handleRevokeOrDelete = async (tokenItem) => { if (!userToken) return; setLoading(true); const endpoint = tokenItem.is_active ? "revoke" : "remove"; try { - const res = await fetch(`${API_BASE_URL}/${endpoint}`, { + const res = await fetch(`https://api.docsaid.org/public/token/${endpoint}`, { method: "POST", headers: { Authorization: `Bearer ${userToken}`, @@ -255,11 +338,9 @@ export default function DashboardApiKey() { }); if (!res.ok) { const e = await res.json().catch(() => ({})); - throw new Error(e.detail || `Operation failed: ${res.status}`); + throw new Error(e.detail || text.operationFailed.replace("{0}", res.status)); } - message.success( - tokenItem.is_active ? text.tokenRevoked : text.tokenDeleted - ); + message.success(tokenItem.is_active ? text.tokenRevoked : text.tokenDeleted); await fetchTokens(); } catch (err) { message.error(err.message); @@ -269,25 +350,24 @@ export default function DashboardApiKey() { }; // ======================== - // 複製 Token (於新建後的 Modal) + // 複製 Token(新建後 Modal 使用) // ======================== const copyToken = async (tokenStr) => { if (!tokenStr) { - message.error(text.copyFailure || "複製失敗"); + message.error(text.copyFailure); return; } try { await navigator.clipboard.writeText(tokenStr); - // ★ 第 3 點:message.success => 提示「已複製」 - message.success("已複製"); + message.success(text.copySuccess); } catch { - message.error(text.copyFailure || "複製失敗"); + message.error(text.copyFailure); } }; - // 遮罩 jti + // 遮罩 jti(若未顯示明碼) const maskToken = (val) => { - if (!val) return "N/A"; + if (!val) return text.notAvailable; if (showTokenPlain) return val; if (val.length < 10) { return val.slice(0, 2) + "****" + val.slice(-2); @@ -295,15 +375,15 @@ export default function DashboardApiKey() { return val.slice(0, 6) + "****" + val.slice(-4); }; - // 顯示計費方案 + // 顯示計費方案(使用 i18n) function getPlanLabel(billingType) { switch (billingType) { case "rate_limit": - return "Basic (Free)"; + return text.basicPlan; case "pay_per_use": - return "Pay-As-You-Go"; + return text.payPerUsePlan; default: - return "Unknown Plan"; + return text.unknownPlan; } } const renderPlanBox = () => { @@ -322,7 +402,7 @@ export default function DashboardApiKey() { if (loadingProfile && !userProfile) { return (
- +
); } @@ -330,11 +410,8 @@ export default function DashboardApiKey() { if (userProfile && !userProfile.is_email_verified) { return (
-

請先驗證電子郵件

-

- 您尚未完成電子郵件驗證,無法使用 API Token 功能。
- 請前往 MyInfo 頁面完成驗證。 -

+

{text.pleaseVerifyEmailTitle}

+

{text.pleaseVerifyEmailDesc.replace("{0}", text.myInfoPage)}

); } @@ -369,26 +446,17 @@ export default function DashboardApiKey() { - {/* 第4點: 調整排列 / 間距 */} item.jti} renderItem={(item) => { - // 將 expires_at (UTC) => 本地時間字串 - const localExpires = item.expires_at - ? formatToLocalTime(item.expires_at) - : ""; // 避免顯示 "forever" 改成空 - + const localExpires = item.expires_at ? formatToLocalTime(item.expires_at) : ""; return ( @@ -412,15 +480,13 @@ export default function DashboardApiKey() { {/* 新建 Token 後一次性顯示 */} setNewTokenModalVisible(false)} footer={null} destroyOnClose > -

- {text.newTokenModalDesc || "請複製並保存,關閉後無法再次查看。"} -

+

{text.newTokenModalDesc}

copyToken(latestCreatedToken)} style={{ marginRight: 8 }} > - {text.copyTokenButton || "複製 Token"} + {text.copyTokenButton}
diff --git a/src/components/Dashboard/ApiKey/locales.js b/src/components/Dashboard/ApiKey/locales.js deleted file mode 100644 index 966c885a513..00000000000 --- a/src/components/Dashboard/ApiKey/locales.js +++ /dev/null @@ -1,211 +0,0 @@ -// src/components/Dashboard/ApiKey/locales.js -export const apiKeyLocale = { - "zh-hant": { - planBasic: "基本 (免費)", - planProfessional: "專業版", - planPayAsYouGo: "隨用隨付", - planUnknown: "未知", - popconfirmRevokeTitle: "確定撤銷這個 Token 嗎?", - popconfirmDeleteTitle: "確定刪除這個 Token 嗎?", - revokeButton: "撤銷", - deleteButton: "刪除", - defaultTokenName: "My Token", - tokenIdLabel: "Token (ID):", - tooltipCopyToken: "點擊複製 Token JTI", - expiryLabel: "到期時間:", - forever: "永久", - statusLabel: "狀態:", - active: "Active", - revoked: "Revoked", - detailUsageButton: "詳細 / 用量", - headerTitle: "我的 API Keys", - headerDescription: "管理、檢視、撤銷與刪除你的公開 Token", - createTokenButton: "建立新 Token", - toggleShowTokens: "顯示全部 Token", - toggleHideTokens: "隱藏全部 Token", - createModalTitle: "建立新的公開 Token", - formTokenNameLabel: "Token 名稱", - formTokenNameTooltip: "可給 Token 一個易識別的名稱", - formTokenNamePlaceholder: "如:My DocAligner Key", - formPlanLabel: "方案", - formPermanentLabel: "永久 (不過期)", - formPermanentCheckbox: "若勾選,忽略「有效期」", - formExpiryLabel: "有效期 (分鐘)", - formExpiryValidationMessage: "請輸入有效期", - cancelButton: "取消", - createButton: "建立", - collapseHeader: "我的 Token 列表", - drawerDetail: "詳細", - drawerInfo: "Token Info", - drawerTokenIdLabel: "Token (ID, 遮罩): ", - drawerExpiryLabel: "到期時間:", - drawerStatusLabel: "狀態:", - tabUsageInfo: "用量資訊", - usageInfoInstruction: "按下「查詢用量」按鈕來顯示最新使用量。", - checkUsageButton: "查詢用量", - usageNote: "※ 若 Token 已撤銷 (或未保存完整 JWT),將無法查用量。", - tabOthers: "其他", - othersContent: "你可在此放一些額外資訊、說明或使用範例。", - notLoggedIn: "尚未登入,無法建立 Token", - createTokenSuccessTitle: "Token 已建立!", - createTokenSuccessContent: - "請妥善保存以下 Token(完整 JWT),未來不會再次顯示:", - parseJtiWarning: "無法解析 jti,之後無法查用量", - copySuccess: "已複製", - copyFailure: "複製失敗", - usageThisHourLabel: "本小時用量:", - remainingLabel: "剩餘:", - payPerUseInfo: "Pay-Per-Use, 無上限", - missingFullToken: "無法查用量,缺少完整 Token", - apiUsageExampleTitle: "API 使用範例", - apiUsageYourPublicToken: "你的公開 Token", - apiUsageInputPlaceholder: "輸入你的公開 Token", - apiUsageCheckUsageButton: "查詢使用量", - apiUsageBillingType: "計費模式", - apiUsageUsedThisHour: "本小時已用次數", - apiUsageRemaining: "剩餘次數", - currentPlanLabel: "目前方案", - upgradeNotImplemented: "尚未開放", - upgradeButtonLabel: "升級", - }, - en: { - planBasic: "Basic (Free)", - planProfessional: "Professional", - planPayAsYouGo: "PayAsYouGo", - planUnknown: "Unknown", - popconfirmRevokeTitle: "Are you sure you want to revoke this token?", - popconfirmDeleteTitle: "Are you sure you want to delete this token?", - revokeButton: "Revoke", - deleteButton: "Delete", - defaultTokenName: "My Token", - tokenIdLabel: "Token (ID):", - tooltipCopyToken: "Click to copy token JTI", - expiryLabel: "Expiry:", - forever: "Forever", - statusLabel: "Status:", - active: "Active", - revoked: "Revoked", - detailUsageButton: "Details / Usage", - headerTitle: "My API Keys", - headerDescription: "Manage, view, revoke, and delete your public tokens", - createTokenButton: "Create New Token", - toggleShowTokens: "Show All Tokens", - toggleHideTokens: "Hide All Tokens", - createModalTitle: "Create New Public Token", - formTokenNameLabel: "Token Name", - formTokenNameTooltip: "Give the token an easily recognizable name", - formTokenNamePlaceholder: "e.g., My DocAligner Key", - formPlanLabel: "Plan", - formPermanentLabel: "Permanent (No Expiry)", - formPermanentCheckbox: "If checked, ignores the expiration period", - formExpiryLabel: "Expiry (minutes)", - formExpiryValidationMessage: "Please enter the expiration time", - cancelButton: "Cancel", - createButton: "Create", - collapseHeader: "My Token List", - drawerDetail: "Details", - drawerInfo: "Token Info", - drawerTokenIdLabel: "Token (ID, Masked): ", - drawerExpiryLabel: "Expiry:", - drawerStatusLabel: "Status:", - tabUsageInfo: "Usage Info", - usageInfoInstruction: "Click the 'Check Usage' button to display the latest usage.", - checkUsageButton: "Check Usage", - usageNote: "※ If the token has been revoked (or the full JWT is not saved), usage cannot be checked.", - tabOthers: "Others", - othersContent: "You can place additional information, instructions, or usage examples here.", - notLoggedIn: "Not logged in, unable to create token", - createTokenSuccessTitle: "Token Created!", - createTokenSuccessContent: - "Please securely save the following token (full JWT); it will not be shown again:", - parseJtiWarning: "Unable to parse jti; usage cannot be checked later", - copySuccess: "Copied", - copyFailure: "Copy failed", - usageThisHourLabel: "Usage this hour:", - remainingLabel: "Remaining:", - payPerUseInfo: "Pay-Per-Use, No Limit", - missingFullToken: "Unable to check usage, full token is missing", - apiUsageExampleTitle: "API Usage Examples", - apiUsageYourPublicToken: "Your Public Token", - apiUsageInputPlaceholder: "Enter your Public Token", - apiUsageCheckUsageButton: "Check Usage", - apiUsageBillingType: "Billing Type", - apiUsageUsedThisHour: "Usage This Hour", - apiUsageRemaining: "Remaining", - currentPlanLabel: "Current Plan", - upgradeNotImplemented: "Not implemented.", - upgradeButtonLabel: "Upgrade Now", - }, - ja: { - planBasic: "ベーシック (無料)", - planProfessional: "プロフェッショナル", - planPayAsYouGo: "従量課金", - planUnknown: "不明", - popconfirmRevokeTitle: "このトークンを取り消してもよろしいですか?", - popconfirmDeleteTitle: "このトークンを削除してもよろしいですか?", - revokeButton: "取り消し", - deleteButton: "削除", - defaultTokenName: "My Token", - tokenIdLabel: "トークン (ID):", - tooltipCopyToken: "トークンJTIをコピーするにはクリック", - expiryLabel: "有効期限:", - forever: "無期限", - statusLabel: "状態:", - active: "有効", - revoked: "無効", - detailUsageButton: "詳細 / 使用状況", - headerTitle: "マイAPIキー", - headerDescription: "公開トークンの管理、確認、取り消し、削除", - createTokenButton: "新規トークン作成", - toggleShowTokens: "全トークンを表示", - toggleHideTokens: "全トークンを隠す", - createModalTitle: "新しい公開トークンを作成", - formTokenNameLabel: "トークン名", - formTokenNameTooltip: "トークンにわかりやすい名前を付けることができます", - formTokenNamePlaceholder: "例:My DocAligner Key", - formPlanLabel: "プラン", - formPermanentLabel: "永久(期限なし)", - formPermanentCheckbox: "チェックすると、有効期限を無視します", - formExpiryLabel: "有効期限(分)", - formExpiryValidationMessage: "有効期限を入力してください", - cancelButton: "キャンセル", - createButton: "作成", - collapseHeader: "マイトークン一覧", - drawerDetail: "詳細", - drawerInfo: "トークン情報", - drawerTokenIdLabel: "トークン (ID, マスク済み): ", - drawerExpiryLabel: "有効期限:", - drawerStatusLabel: "状態:", - tabUsageInfo: "使用状況情報", - usageInfoInstruction: - "「使用状況確認」ボタンをクリックして最新の使用状況を表示します。", - checkUsageButton: "使用状況確認", - usageNote: - "※ トークンが取り消されている場合(または完全なJWTが保存されていない場合)、使用状況を確認できません。", - tabOthers: "その他", - othersContent: "ここに追加情報、説明、または使用例を配置できます。", - notLoggedIn: "ログインしていないため、トークンを作成できません", - createTokenSuccessTitle: "トークンが作成されました!", - createTokenSuccessContent: - "以下のトークン(完全なJWT)を安全に保存してください。今後再表示されることはありません:", - parseJtiWarning: - "jtiの解析に失敗しました。後で使用状況を確認できなくなります", - copySuccess: "コピーしました", - copyFailure: "コピーに失敗しました", - usageThisHourLabel: "今時間の使用量:", - remainingLabel: "残り:", - payPerUseInfo: "従量課金, 上限なし", - missingFullToken: "使用状況を確認できません。完全なトークンがありません", - // 統一 API 使用範例相關鍵值,全部改為 apiUsage 前綴 - apiUsageExampleTitle: "API利用例", - apiUsageYourPublicToken: "あなたの公開トークン", - apiUsageInputPlaceholder: "あなたの公開トークンを入力してください", - apiUsageCheckUsageButton: "使用状況確認", - apiUsageBillingType: "請求タイプ", - apiUsageUsedThisHour: "今時間の使用量", - apiUsageRemaining: "残り", - currentPlanLabel: "Current Plan", - upgradeNotImplemented: "Not implemented.", - upgradeButtonLabel: "Upgrade Now", - }, -}; From cb6697b7482cb8c051a850ad977397da7587df78 Mon Sep 17 00:00:00 2001 From: zephyr-sh Date: Sat, 15 Mar 2025 15:02:40 +0800 Subject: [PATCH 20/25] [C] Update usage renew --- .../Dashboard/ApiKey/UsageOverview.js | 144 +++++++++++++++--- .../Dashboard/ApiKey/UsageOverview.module.css | 69 +++++++++ src/components/Dashboard/ApiKey/index.js | 71 +++++++-- src/components/Dashboard/MyInfo/index.js | 56 +++---- .../Dashboard/MyInfo/index.module.css | 96 ++++++++++++ src/components/Dashboard/MyInfo/locales.js | 2 +- src/pages/dashboard.js | 2 + 7 files changed, 371 insertions(+), 69 deletions(-) create mode 100644 src/components/Dashboard/ApiKey/UsageOverview.module.css create mode 100644 src/components/Dashboard/MyInfo/index.module.css diff --git a/src/components/Dashboard/ApiKey/UsageOverview.js b/src/components/Dashboard/ApiKey/UsageOverview.js index 01c86264f3e..375e2d0c9ab 100644 --- a/src/components/Dashboard/ApiKey/UsageOverview.js +++ b/src/components/Dashboard/ApiKey/UsageOverview.js @@ -1,42 +1,56 @@ -// src/components/Dashboard/ApiKey/UsageOverview.jsx +import { ReloadOutlined } from "@ant-design/icons"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; -import { Card, Progress } from "antd"; +import { Button, Card, Divider, Progress } from "antd"; import PropTypes from "prop-types"; import React from "react"; +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import styles from "./UsageOverview.module.css"; const apiKeyLocale = { "zh-hant": { loadingUsage: "載入使用情況...", usageOverviewTitle: "用量概覽", + usageHistoryTitle: "歷史使用趨勢", usageThisHourLabel: "本小時用量:", remainingLabel: "剩餘:", + refreshButton: "更新用量", }, en: { loadingUsage: "Loading usage...", usageOverviewTitle: "Usage Overview", + usageHistoryTitle: "Historical Usage Trends", usageThisHourLabel: "Usage this hour:", remainingLabel: "Remaining:", + refreshButton: "Refresh", }, ja: { loadingUsage: "使用状況を読み込み中...", usageOverviewTitle: "使用状況概観", + usageHistoryTitle: "過去の使用傾向", usageThisHourLabel: "今時間の使用量:", remainingLabel: "残り:", + refreshButton: "リフレッシュ", }, }; -const cardStyle = { marginBottom: 24, borderRadius: 8 }; - -export default function UsageOverview({ userUsage }) { +export default function UsageOverview({ userUsage, usageHistory, onRefresh }) { const { i18n: { currentLocale }, } = useDocusaurusContext(); const text = apiKeyLocale[currentLocale] || apiKeyLocale.en; + // 若後端尚未回傳 userUsage,顯示 loading 狀態 if (!userUsage) { - // 可依需求改為 skeleton 或 loading return ( - +

{text.loadingUsage}

); @@ -44,52 +58,119 @@ export default function UsageOverview({ userUsage }) { const { billing_type, used_this_hour, limit_per_hour, remaining } = userUsage; - // 當 limit_per_hour 為 0 時避免除以 0 錯誤 + // 計算當前用量的百分比,避免 division by zero const percent = billing_type === "rate_limit" && limit_per_hour > 0 ? Math.min(100, (used_this_hour / limit_per_hour) * 100) : 0; + // 點擊刷新按鈕時,若沒有傳入 onRefresh 回呼,預設使用 console.log + const handleRefreshClick = () => { + if (onRefresh) { + onRefresh(); + } else { + console.log("No onRefresh callback provided."); + } + }; + + // 你也可以在父元件就把時間轉好,再給 usageHistory + // 如果此處仍是 UTC,可用 tickFormatter 轉為當地時間 + const formatLocalTime = (val) => { + // 若父層已是 local time string,可直接回傳 val + // 若 val 仍是 UTC 格式,如 "2025-03-14T12:00:00Z",則可自行轉換 + const dt = new Date(val); + if (Number.isNaN(dt.getTime())) { + return val; // 無法轉換就直接回傳原字串 + } + // 回傳當地時區的 HH:MM (或更完整格式) + return dt.toLocaleString(); + }; + return ( - + + {/* + 1) 刷新按鈕放在最上方,使用 primary 樣式使其更顯眼 + */} +
+

{text.usageOverviewTitle}

+ +
+ + {/* 2) 用量進度顯示區 */} {billing_type === "rate_limit" ? ( -
+
= limit_per_hour ? "exception" : "normal"} size={80} format={() => `${used_this_hour}/${limit_per_hour}`} + className={styles.progressCircle} /> -
-

{text.usageOverviewTitle}

-

- {text.usageThisHourLabel} - {used_this_hour}/{limit_per_hour} -  •  - {text.remainingLabel} - {remaining} +

+

+ {text.usageThisHourLabel} {used_this_hour}/{limit_per_hour} + + {text.remainingLabel} {remaining}

) : billing_type === "pay_per_use" ? ( -
+
`${used_this_hour}`} + className={styles.progressCircle} /> -
-

{text.usageOverviewTitle}

-

- {text.usageThisHourLabel} - {used_this_hour} (Pay-As-You-Go) +

+

+ {text.usageThisHourLabel} {used_this_hour} (Pay-As-You-Go)

) : ( -

Unknown billing type

+

Unknown billing type

+ )} + + {/* 3) 歷史用量折線圖 */} + {usageHistory && usageHistory.length > 0 && ( + <> + +

{text.usageHistoryTitle}

+ + + + + + { + // ToolTip 也做本地時間顯示 + return formatLocalTime(label); + }} + /> + + + + )} ); @@ -102,4 +183,17 @@ UsageOverview.propTypes = { limit_per_hour: PropTypes.number, remaining: PropTypes.number, }), + usageHistory: PropTypes.arrayOf( + PropTypes.shape({ + time: PropTypes.string.isRequired, // 從父層拿到的時間字串 + used: PropTypes.number.isRequired, + }) + ), + onRefresh: PropTypes.func, +}; + +UsageOverview.defaultProps = { + userUsage: null, + usageHistory: [], + onRefresh: null, }; diff --git a/src/components/Dashboard/ApiKey/UsageOverview.module.css b/src/components/Dashboard/ApiKey/UsageOverview.module.css new file mode 100644 index 00000000000..9f0f97cb2a9 --- /dev/null +++ b/src/components/Dashboard/ApiKey/UsageOverview.module.css @@ -0,0 +1,69 @@ +.usageCard { + margin-bottom: 24px; + border-radius: 8px; + padding: 16px; + background-color: #fff; + /* 視需要可加陰影:box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); */ +} + +/* 最上方區域(標題與刷新按鈕) */ +.topBar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.mainTitle { + margin: 0; + font-size: 1.5rem; + color: #333; +} + +.refreshButton { + /* 讓刷新按鈕更醒目 */ + margin-left: 12px; +} + +/* 進度與用量區域 */ +.usageHeader { + display: flex; + align-items: center; + gap: 24px; + margin-bottom: 16px; +} + +.progressCircle { + flex-shrink: 0; +} + +.usageDetails { + flex-grow: 1; +} + +.usageText { + margin: 0; + font-size: 1rem; + color: #555; +} + +.divider { + margin: 0 8px; + font-size: 1rem; + color: #999; +} + +/* 歷史圖表區 */ +.dividerStyle { + margin: 16px 0; +} + +.historyTitle { + margin-bottom: 16px; + font-size: 1.2rem; + color: #333; +} + +.errorText { + color: red; +} diff --git a/src/components/Dashboard/ApiKey/index.js b/src/components/Dashboard/ApiKey/index.js index 3f7926d0f3c..01322e42db8 100644 --- a/src/components/Dashboard/ApiKey/index.js +++ b/src/components/Dashboard/ApiKey/index.js @@ -1,3 +1,4 @@ +// src/components/Dashboard/ApiKey/index.js import { CopyOutlined, EyeInvisibleOutlined, @@ -22,8 +23,8 @@ const apiKeyLocale = { headerTitle: "我的 API Keys", headerDescription: "管理、檢視、撤銷與刪除你的公開 Token", currentPlanLabel: "目前方案", - toggleShowTokens: "顯示全部 Token", - toggleHideTokens: "隱藏全部 Token", + toggleShowTokens: "顯示全部", + toggleHideTokens: "隱藏全部", createTokenButton: "建立新 Token", collapseHeader: "我的 Token 列表", apiUsageExampleTitle: "API 使用範例", @@ -56,8 +57,8 @@ const apiKeyLocale = { headerTitle: "My API Keys", headerDescription: "Manage, view, revoke, and delete your public tokens", currentPlanLabel: "Current Plan", - toggleShowTokens: "Show All Tokens", - toggleHideTokens: "Hide All Tokens", + toggleShowTokens: "Show All", + toggleHideTokens: "Hide All", createTokenButton: "Create New Token", collapseHeader: "My Token List", apiUsageExampleTitle: "API Usage Examples", @@ -90,8 +91,8 @@ const apiKeyLocale = { headerTitle: "マイAPIキー", headerDescription: "公開トークンの管理、確認、取り消し、削除", currentPlanLabel: "Current Plan", - toggleShowTokens: "全トークンを表示", - toggleHideTokens: "全トークンを隠す", + toggleShowTokens: "すべて表示", + toggleHideTokens: "すべて非表示", createTokenButton: "新規トークン作成", collapseHeader: "マイトークン一覧", apiUsageExampleTitle: "API利用例", @@ -157,6 +158,7 @@ export default function DashboardApiKey() { const [loadingProfile, setLoadingProfile] = useState(false); const [apiKeys, setApiKeys] = useState([]); const [userUsage, setUserUsage] = useState(null); + const [usageHistory, setUsageHistory] = useState([]); // 新增:歷史用量資料 const [loading, setLoading] = useState(false); const [createModalVisible, setCreateModalVisible] = useState(false); const [newTokenModalVisible, setNewTokenModalVisible] = useState(false); @@ -237,14 +239,53 @@ export default function DashboardApiKey() { } }, [userToken, text]); + // ======================== + // 新增:抓取歷史用量資料 + // ======================== + const fetchUsageHistory = useCallback(async () => { + if (!userToken) return; + try { + const res = await fetch("https://api.docsaid.org/public/token/usage-history-minute", { + headers: { Authorization: `Bearer ${userToken}` }, + }); + if (!res.ok) { + throw new Error("Failed to fetch usage history"); + } + const historyData = await res.json(); + + // 將 UTC 時間轉當地時間字串 + const localHistory = historyData.map((item) => { + const dt = new Date(item.time); + const localTimeStr = dt.toLocaleString(); + return { + ...item, + time: localTimeStr, + }; + }); + + setUsageHistory(localHistory); + } catch (err) { + console.error(err); + } + }, [userToken]); + + // ======================== + // 封裝「刷新用量」函式 + // 同時抓取 user-usage 與 usage-history-minute + // ======================== + const refreshUsageData = useCallback(async () => { + await fetchUserUsage(); + await fetchUsageHistory(); + }, [fetchUserUsage, fetchUsageHistory]); + // ======================== // 頁面初始載入 // ======================== useEffect(() => { fetchUserProfile(); fetchTokens(); - fetchUserUsage(); - }, [fetchUserProfile, fetchTokens, fetchUserUsage]); + refreshUsageData(); + }, [fetchUserProfile, fetchTokens, refreshUsageData]); // ======================== // 申請新 Token @@ -325,6 +366,12 @@ export default function DashboardApiKey() { // ======================== const handleRevokeOrDelete = async (tokenItem) => { if (!userToken) return; + + if (tokenItem.__frontend_expired) { + message.error(text.tokenDeleted); + return; + } + setLoading(true); const endpoint = tokenItem.is_active ? "revoke" : "remove"; try { @@ -426,7 +473,13 @@ export default function DashboardApiKey() { {/* 顯示方案 & 用量 */} {renderPlanBox()} - + + {/* 傳入 usageHistory 使 UsageOverview 顯示更多資訊 */} + {/* 操作按鈕 */}
diff --git a/src/components/Dashboard/MyInfo/index.js b/src/components/Dashboard/MyInfo/index.js index 10916777070..9c48c07d9bd 100644 --- a/src/components/Dashboard/MyInfo/index.js +++ b/src/components/Dashboard/MyInfo/index.js @@ -1,3 +1,4 @@ +// src/components/Dashboard/MyInfo/index.js import { UploadOutlined, UserOutlined } from "@ant-design/icons"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { @@ -20,6 +21,7 @@ import moment from "moment"; import React, { useCallback, useEffect, useState } from "react"; import PasswordInput from "../../../components/PasswordInput"; import { useAuth } from "../../../context/AuthContext"; +import styles from "./index.module.css"; // 引入 CSS 模組 import { changePasswordLocale, dashboardLocale, deleteAccountLocale } from "./locales"; const { Text } = Typography; @@ -224,12 +226,11 @@ export default function DashboardMyInfo() { }; if (infoLoading) { - return ; + return ; } // 修正 Alert 中替換字串的問題 const renderNoEmailAlertDescription = () => { - // 假設 text.noEmailAlertDesc 為 "請設定您的 Email,點擊 {editLink} 進行設定" const parts = text.noEmailAlertDesc.split("{editLink}"); return ( <> @@ -260,12 +261,14 @@ export default function DashboardMyInfo() { }; return ( -
-

{text.myInfoTitle}

+
+
+

{text.myInfoTitle}

+
{showEmailAlert && user?.email && !user.is_email_verified && ( -
-
+
+
} + alt="User Avatar" onError={() => false} />
@@ -306,7 +302,7 @@ export default function DashboardMyInfo() { accept="image/*" customRequest={onUploadAvatar} > -
- + {editing ? ( {user?.is_email_verified === false ? : } @@ -375,23 +365,23 @@ export default function DashboardMyInfo() { /> - - ) : ( -
+

{text.accountLabel}:{user?.username || text.notSet}

{text.emailLabel}:{user?.email || text.notSet} {user?.email && ( - + ({text.statusLabel}:{renderEmailStatus()}) )} @@ -413,7 +403,7 @@ export default function DashboardMyInfo() { {text.lastLoginIpLabel} {lastLoginIp}

-
@@ -466,9 +456,7 @@ export default function DashboardMyInfo() { {/@example\.com$/i.test(user?.email) ? (

{text.verificationModalExampleEmail}

) : ( - <> -

{text.verificationModalDesc.replace("{email}", user?.email)}

- +

{text.verificationModalDesc.replace("{email}", user?.email)}

)} diff --git a/src/components/Dashboard/MyInfo/index.module.css b/src/components/Dashboard/MyInfo/index.module.css new file mode 100644 index 00000000000..ffa76f77971 --- /dev/null +++ b/src/components/Dashboard/MyInfo/index.module.css @@ -0,0 +1,96 @@ +/* src/components/Dashboard/MyInfo/index.module.css */ + +.container { + max-width: 900px; + margin: 0 auto; + padding: 24px; + background-color: #fafafa; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + } + + /* 統一頁面標題 */ + .header { + margin-bottom: 24px; + text-align: left; + } + + .header h2 { + font-size: 2rem; + margin: 0; + color: #333; + } + + /* 針對手機、平板調整容器間距 */ + @media (max-width: 576px) { + .container { + padding: 16px; + } + .header h2 { + font-size: 1.5rem; + } + } + + /* Alert 設定 */ + .alert { + margin-bottom: 16px; + } + + /* Avatar 與上傳按鈕 */ + .avatarContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + + .uploadButton { + margin-top: 8px; + } + + /* 調整 Avatar 尺寸於小裝置 */ + @media (max-width: 576px) { + .avatarContainer .ant-avatar { + width: 120px; + height: 120px; + font-size: 1rem; + } + } + + /* 表單與個人資訊區塊 */ + .profileInfo { + font-size: 1rem; + color: #333; + } + + .profileInfo p { + margin-bottom: 8px; + } + + /* Email 狀態提示 */ + .emailStatus { + margin-left: 8px; + } + + /* 編輯按鈕樣式 */ + .editButton { + padding: 0; + font-size: 1rem; + text-decoration: underline; + } + + /* 按鈕間距調整 */ + .saveButton { + margin-right: 8px; + } + + + /* 平板與桌面,進一步優化間距 */ + @media (min-width: 768px) and (max-width: 991px) { + .container { + padding: 20px; + } + .header h2 { + font-size: 1.75rem; + } + } diff --git a/src/components/Dashboard/MyInfo/locales.js b/src/components/Dashboard/MyInfo/locales.js index 70a11f51e9f..1921a07520d 100644 --- a/src/components/Dashboard/MyInfo/locales.js +++ b/src/components/Dashboard/MyInfo/locales.js @@ -3,7 +3,7 @@ export const dashboardLocale = { myInfoTitle: "我的資訊", emailNotVerifiedAlertTitle: "您的 Email 尚未驗證", emailNotVerifiedAlertDesc: - "沒有驗證信箱,帳號密碼丟失後無法找回。請點選下方寄送驗證信,並檢查您的信箱。", + "我們會固定時間清除沒有驗證信箱的帳號。請點選下方寄送驗證信,並檢查您的信箱。", noEmailAlertTitle: "尚未填寫 Email", noEmailAlertDesc: "您尚未綁定 Email,請點 {editLink} 補上 Email。", uploadAvatarButtonUploading: "上傳中...", diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index 357611ff0b0..92b49d7fb95 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -196,6 +196,8 @@ export default function DashboardPage() { collapsible collapsed={collapsed} onCollapse={setCollapsed} + breakpoint="md" + onBreakpoint={(broken) => setCollapsed(broken)} style={{ borderRight: "1px solid #ddd" }} >
Date: Sat, 15 Mar 2025 16:25:23 +0800 Subject: [PATCH 21/25] [C] Update apikey settings --- .../Dashboard/ApiKey/ApiUsageExamples.js | 157 ++++++++++-------- .../Dashboard/ApiKey/CreateTokenModal.js | 6 +- src/components/Dashboard/ApiKey/index.js | 140 +++++++++++----- 3 files changed, 188 insertions(+), 115 deletions(-) diff --git a/src/components/Dashboard/ApiKey/ApiUsageExamples.js b/src/components/Dashboard/ApiKey/ApiUsageExamples.js index 5a122694d70..e208aa6d226 100644 --- a/src/components/Dashboard/ApiKey/ApiUsageExamples.js +++ b/src/components/Dashboard/ApiKey/ApiUsageExamples.js @@ -170,78 +170,95 @@ puts response.body`, }, }; + // 建立「第二層 Tabs」的 items - 例如 DocAligner + const makeInnerTabs = (dataObj) => { + // dataObj: examples.docaligner 或 examples.mrzscanner + return [ + { + label: "cURL", + key: "curl", + children: ( +
+            {dataObj.curl}
+          
+ ), + }, + { + label: "Python", + key: "python", + children: ( +
+            {dataObj.python}
+          
+ ), + }, + { + label: "Node.js", + key: "node", + children: ( +
+            {dataObj.node}
+          
+ ), + }, + { + label: "JavaScript", + key: "javascript", + children: ( +
+            {dataObj.javascript}
+          
+ ), + }, + { + label: "Java", + key: "java", + children: ( +
+            {dataObj.java}
+          
+ ), + }, + { + label: "Ruby", + key: "ruby", + children: ( +
+            {dataObj.ruby}
+          
+ ), + }, + ]; + }; + + // 第一層 Tabs items + const topItems = [ + { + label: "DocAligner", + key: "docaligner", + children: ( + + ), + }, + { + label: "MRZ Scanner", + key: "mrzscanner", + children: ( + + ), + }, + ]; + return ( - - - - -
-                {examples.docaligner.curl}
-              
-
- -
-                {examples.docaligner.python}
-              
-
- -
-                {examples.docaligner.node}
-              
-
- -
-                {examples.docaligner.javascript}
-              
-
- -
-                {examples.docaligner.java}
-              
-
- -
-                {examples.docaligner.ruby}
-              
-
-
-
- - - -
-                {examples.mrzscanner.curl}
-              
-
- -
-                {examples.mrzscanner.python}
-              
-
- -
-                {examples.mrzscanner.node}
-              
-
- -
-                {examples.mrzscanner.javascript}
-              
-
- -
-                {examples.mrzscanner.java}
-              
-
- -
-                {examples.mrzscanner.ruby}
-              
-
-
-
-
+
); } + +// 也可另行定義 +const preStyle = { + background: "#f5f5f5", + padding: 12, + whiteSpace: "pre-wrap", +}; \ No newline at end of file diff --git a/src/components/Dashboard/ApiKey/CreateTokenModal.js b/src/components/Dashboard/ApiKey/CreateTokenModal.js index b9e58cb01fc..dc6aec26489 100644 --- a/src/components/Dashboard/ApiKey/CreateTokenModal.js +++ b/src/components/Dashboard/ApiKey/CreateTokenModal.js @@ -60,8 +60,10 @@ export default function CreateTokenModal({ const text = apiKeyLocale[currentLocale] || apiKeyLocale.en; useEffect(() => { - form.setFieldsValue(defaultValues); - }, [defaultValues, form]); + if (visible) { + form.setFieldsValue(defaultValues); + } + }, [defaultValues, form, visible]); return ( { + if (!tokenStr) return; + try { + await navigator.clipboard.writeText(tokenStr); + setTooltipTitle(text.copySuccess); // 設為「已複製」 + setVisible(true); + + // 1 秒後自動隱藏 tooltip,回復初始狀態 + setTimeout(() => { + setVisible(false); + setTooltipTitle(text.copyTokenButton); + }, 1000); + } catch { + // 若要顯示錯誤,可在此提示 + console.error("Failed to copy token"); + } + }; + + return ( + setVisible(v)} + > + + + ); +} + /** 將 UTC 時間字串轉成本地時間 */ function formatToLocalTime(utcString) { if (!utcString) return ""; @@ -253,10 +294,20 @@ export default function DashboardApiKey() { } const historyData = await res.json(); - // 將 UTC 時間轉當地時間字串 + // 轉換 UTC+0 時間為使用者當地時區時間 const localHistory = historyData.map((item) => { - const dt = new Date(item.time); - const localTimeStr = dt.toLocaleString(); + const dt = new Date(item.time + "Z"); // 確保時間被解析為 UTC + const localTimeStr = dt.toLocaleString(undefined, { + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, // 取得使用者當前時區 + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, // 24 小時制 (根據需求調整) + }); + return { ...item, time: localTimeStr, @@ -267,7 +318,7 @@ export default function DashboardApiKey() { } catch (err) { console.error(err); } - }, [userToken]); +}, [userToken]); // ======================== // 封裝「刷新用量」函式 @@ -367,13 +418,9 @@ export default function DashboardApiKey() { const handleRevokeOrDelete = async (tokenItem) => { if (!userToken) return; - if (tokenItem.__frontend_expired) { - message.error(text.tokenDeleted); - return; - } - setLoading(true); const endpoint = tokenItem.is_active ? "revoke" : "remove"; + try { const res = await fetch(`https://api.docsaid.org/public/token/${endpoint}`, { method: "POST", @@ -497,31 +544,41 @@ export default function DashboardApiKey() {
- - - item.jti} - renderItem={(item) => { - const localExpires = item.expires_at ? formatToLocalTime(item.expires_at) : ""; - return ( - - - - ); - }} - /> - - - - - + item.jti} + renderItem={(item) => { + const localExpires = item.expires_at ? formatToLocalTime(item.expires_at) : ""; + return ( + + + + ); + }} + /> + ), + }, + { + key: "apiUsage", + label: text.apiUsageExampleTitle, + children: , + }, + ]} + /> {/* 建立 Token 的 Modal */} {latestCreatedToken}
- + + + + ); From 9457802539c240a5a8b252ff27159d433f517fe7 Mon Sep 17 00:00:00 2001 From: zephyr-sh Date: Sat, 15 Mar 2025 21:11:17 +0800 Subject: [PATCH 22/25] [C] Update css settings --- src/components/Dashboard/MyInfo/index.js | 2 +- src/components/Dashboard/MyInfo/locales.js | 6 +- src/components/forms/RegisterForm.js | 59 +++++++---- src/pages/DashboardPage.module.css | 67 +++++++++++++ src/pages/dashboard.js | 111 ++++++++++----------- 5 files changed, 161 insertions(+), 84 deletions(-) create mode 100644 src/pages/DashboardPage.module.css diff --git a/src/components/Dashboard/MyInfo/index.js b/src/components/Dashboard/MyInfo/index.js index 9c48c07d9bd..ebf08809708 100644 --- a/src/components/Dashboard/MyInfo/index.js +++ b/src/components/Dashboard/MyInfo/index.js @@ -21,7 +21,7 @@ import moment from "moment"; import React, { useCallback, useEffect, useState } from "react"; import PasswordInput from "../../../components/PasswordInput"; import { useAuth } from "../../../context/AuthContext"; -import styles from "./index.module.css"; // 引入 CSS 模組 +import styles from "./index.module.css"; import { changePasswordLocale, dashboardLocale, deleteAccountLocale } from "./locales"; const { Text } = Typography; diff --git a/src/components/Dashboard/MyInfo/locales.js b/src/components/Dashboard/MyInfo/locales.js index 1921a07520d..d92ef72ceae 100644 --- a/src/components/Dashboard/MyInfo/locales.js +++ b/src/components/Dashboard/MyInfo/locales.js @@ -3,7 +3,7 @@ export const dashboardLocale = { myInfoTitle: "我的資訊", emailNotVerifiedAlertTitle: "您的 Email 尚未驗證", emailNotVerifiedAlertDesc: - "我們會固定時間清除沒有驗證信箱的帳號。請點選下方寄送驗證信,並檢查您的信箱。", + "我們將定期清除未驗證電子郵件的帳號。若您希望長期保留此帳號,請點擊下方按鈕寄送驗證信,並檢查您的信箱完成驗證。", noEmailAlertTitle: "尚未填寫 Email", noEmailAlertDesc: "您尚未綁定 Email,請點 {editLink} 補上 Email。", uploadAvatarButtonUploading: "上傳中...", @@ -45,7 +45,7 @@ export const dashboardLocale = { myInfoTitle: "My Information", emailNotVerifiedAlertTitle: "Your Email is not verified", emailNotVerifiedAlertDesc: - "Please click the button below to resend the verification email and check your inbox.", + "We regularly remove accounts with unverified email addresses. If you wish to keep your account, please click the button below to send a verification email and check your inbox to complete the verification process.", noEmailAlertTitle: "Email not set", noEmailAlertDesc: "You have not bound an Email. Please click {editLink} to add an Email.", uploadAvatarButtonUploading: "Uploading...", @@ -87,7 +87,7 @@ export const dashboardLocale = { myInfoTitle: "私の情報", emailNotVerifiedAlertTitle: "メールが未認証です", emailNotVerifiedAlertDesc: - "下のボタンをクリックして認証メールを再送信し、受信箱を確認してください。", + "未確認のメールアドレスのアカウントは定期的に削除されます。引き続きアカウントを保持したい場合は、下のボタンをクリックして確認メールを送信し、受信箱をチェックして認証を完了してください。", noEmailAlertTitle: "メール未設定", noEmailAlertDesc: "メールが登録されていません。{editLink} をクリックしてメールを追加してください。", uploadAvatarButtonUploading: "アップロード中...", diff --git a/src/components/forms/RegisterForm.js b/src/components/forms/RegisterForm.js index 5d7e22c3afa..ba536d717c8 100644 --- a/src/components/forms/RegisterForm.js +++ b/src/components/forms/RegisterForm.js @@ -11,6 +11,9 @@ const localeText = { usernameRequired: "請輸入帳號", passwordLabel: "密碼", passwordRequired: "請輸入密碼", + confirmPasswordLabel: "確認密碼", + confirmPasswordRequired: "請再次輸入密碼", + passwordMismatch: "兩次輸入的密碼不一致", registerBtn: "註冊", passphraseHint: "建議使用可記憶的長密碼(如短語)提升安全性。", successMsg: "註冊成功!", @@ -22,6 +25,9 @@ const localeText = { usernameRequired: "Please enter your username", passwordLabel: "Password", passwordRequired: "Please enter your password", + confirmPasswordLabel: "Confirm Password", + confirmPasswordRequired: "Please confirm your password", + passwordMismatch: "The two passwords that you entered do not match", registerBtn: "Register", passphraseHint: "Consider using a memorable passphrase for better security.", successMsg: "Registration successful!", @@ -33,6 +39,9 @@ const localeText = { usernameRequired: "ユーザー名を入力してください", passwordLabel: "パスワード", passwordRequired: "パスワードを入力してください", + confirmPasswordLabel: "パスワードを再入力", + confirmPasswordRequired: "パスワードをもう一度入力してください", + passwordMismatch: "入力された2つのパスワードが一致しません", registerBtn: "登録", passphraseHint: "覚えやすいパスフレーズを使用してセキュリティを向上させましょう。", successMsg: "登録が完了しました!", @@ -46,24 +55,18 @@ export default function RegisterForm({ onLogin, onSuccess, onRegister, loading } i18n: { currentLocale }, } = useDocusaurusContext(); const text = localeText[currentLocale] || localeText.en; - - // 如果後端回 token,可配合 AuthContext 寫入 localStorage const { loginSuccess } = useAuth(); - - // antd form const [form] = Form.useForm(); // 狀態 const [submitError, setSubmitError] = useState(""); const [successMessage, setSuccessMessage] = useState(""); - /** - * 表單送出 - * 不允許弱密碼 => 後端若回 pwned=true,則註冊失敗並顯示錯誤 - */ const onFinish = async (values) => { setSubmitError(""); setSuccessMessage(""); + + // 這裡的 values 會包含 username, password, confirmPassword const result = await onRegister({ username: values.username, password: values.password, @@ -81,14 +84,12 @@ export default function RegisterForm({ onLogin, onSuccess, onRegister, loading } onSuccess?.(); } - // 根據語系決定路徑 - let dashboardPath = '/dashboard'; - if (currentLocale === 'en') { - dashboardPath = '/en/dashboard'; - } else if (currentLocale === 'ja') { - dashboardPath = '/ja/dashboard'; + let dashboardPath = "/dashboard"; + if (currentLocale === "en") { + dashboardPath = "/en/dashboard"; + } else if (currentLocale === "ja") { + dashboardPath = "/ja/dashboard"; } - window.location.href = dashboardPath; } else { if (result.pwned) { @@ -99,9 +100,6 @@ export default function RegisterForm({ onLogin, onSuccess, onRegister, loading } } }; - /** - * 註冊成功後重置表單並自動登入(若後端回 token) - */ const finishRegisterSuccess = (result) => { setSuccessMessage(text.successMsg); form.resetFields(); @@ -153,12 +151,35 @@ export default function RegisterForm({ onLogin, onSuccess, onRegister, loading } + {/* 新增:確認密碼欄位 */} + ({ + validator(_, value) { + if (!value || getFieldValue("password") === value) { + return Promise.resolve(); + } + return Promise.reject(new Error(text.passwordMismatch)); + }, + }), + ]} + > + + + {successMessage && ( )} - {submitError && ( )} diff --git a/src/pages/DashboardPage.module.css b/src/pages/DashboardPage.module.css new file mode 100644 index 00000000000..3029a7c042d --- /dev/null +++ b/src/pages/DashboardPage.module.css @@ -0,0 +1,67 @@ +/* DashboardPage.module.css */ + +/* 整個 Dashboard 的主容器 */ +.dashboardLayout { + min-height: calc(100vh - 60px); + } + + /* 側邊欄的自訂樣式 */ + .customSider { + transition: all 0.3s ease; + border-right: 1px solid #ddd !important; + } + + /* 側邊欄標題部分 */ + .siderTitle { + padding: 16px; + font-weight: bold; + text-align: center; + border-bottom: 1px solid #eee; + transition: all 0.3s ease; + font-size: 1rem; + } + + /* 右側主要區域的布局 */ + .rightSideLayout { + background-color: #f0f2f5; + } + + /* 頂部 Header */ + .header { + border-bottom: 1px solid #ccc; + padding: 0 16px; + transition: background 0.3s ease; + } + + /* 使用者下拉選單文字 */ + .userMenuText { + cursor: pointer; + font-weight: 500; + } + + /* 中間內容區 */ + .contentArea { + padding: 16px; + background-color: #f5f5f5; + min-height: 280px; + } + + /* 內容容器,帶有陰影與圓角 */ + .contentBox { + padding: 24px; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + border-radius: 8px; + } + + /* 載入中時的 Spinner */ + .loadingSpin { + margin: 50px auto; + display: block; + } + + /* 尚未登入時的提示容器 */ + .notLoggedInContainer { + text-align: center; + margin: 50px; + } diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index 92b49d7fb95..f14ad56906a 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -7,7 +7,7 @@ import { PoweroffOutlined, UserOutlined } from "@ant-design/icons"; -import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import Layout from "@theme/Layout"; import { Layout as AntLayout, @@ -19,16 +19,15 @@ import { Row, Spin, theme as antdTheme, - message, + message } from "antd"; import React, { useEffect, useMemo, useState } from "react"; -import { useAuth } from "../context/AuthContext"; - -// Dashboard 子頁面 import DashboardApiKey from "../components/Dashboard/ApiKey"; import DashboardMyInfo from "../components/Dashboard/MyInfo"; +import { useAuth } from "../context/AuthContext"; +import styles from "./DashboardPage.module.css"; -const { Header: AntHeader, Sider, Content, Footer } = AntLayout; +const { Header: AntHeader, Sider, Content } = AntLayout; const localeText = { "zh-hant": { @@ -40,18 +39,18 @@ const localeText = { expanded: "我的後台", menu: { myinfo: "我的資訊", - apikey: "我的 API Key", - }, + apikey: "我的 API Key" + } }, breadcrumb: { dashboard: "我的後台", myinfo: "我的資訊", apikey: "我的 API Key", - undefined: "未定義", + undefined: "未定義" }, userMenu: { backHome: "回主站", - logout: "登出", + logout: "登出" } }, en: { @@ -63,18 +62,18 @@ const localeText = { expanded: "My Dashboard", menu: { myinfo: "My Information", - apikey: "My API Key", - }, + apikey: "My API Key" + } }, breadcrumb: { dashboard: "Dashboard", myinfo: "My Information", apikey: "My API Key", - undefined: "Undefined", + undefined: "Undefined" }, userMenu: { backHome: "Back to Site", - logout: "Logout", + logout: "Logout" } }, ja: { @@ -86,29 +85,30 @@ const localeText = { expanded: "マイダッシュボード", menu: { myinfo: "マイ情報", - apikey: "マイAPIキー", - }, + apikey: "マイAPIキー" + } }, breadcrumb: { dashboard: "ダッシュボード", myinfo: "マイ情報", apikey: "マイAPIキー", - undefined: "未定義", + undefined: "未定義" }, userMenu: { backHome: "サイトへ戻る", - logout: "ログアウト", + logout: "ログアウト" } - }, + } }; export default function DashboardPage() { - const { i18n: { currentLocale } } = useDocusaurusContext(); + const { + i18n: { currentLocale } + } = useDocusaurusContext(); const text = localeText[currentLocale] || localeText.en; const { token, user, loading, logout } = useAuth(); const [selectedKey, setSelectedKey] = useState("myinfo"); const [collapsed, setCollapsed] = useState(false); - const { token: designToken } = antdTheme.useToken(); useEffect(() => { @@ -141,11 +141,11 @@ export default function DashboardPage() { }, [selectedKey, text.breadcrumb]); // 根據語系決定首頁路徑 - let homePath = '/'; - if (currentLocale === 'en') { - homePath = '/en'; - } else if (currentLocale === 'ja') { - homePath = '/ja'; + let homePath = "/"; + if (currentLocale === "en") { + homePath = "/en"; + } else if (currentLocale === "ja") { + homePath = "/ja"; } const userMenuItems = [ @@ -155,25 +155,25 @@ export default function DashboardPage() { label: text.userMenu.backHome, onClick: () => { window.location.href = homePath; - }, + } }, { key: "logout", icon: , label: text.userMenu.logout, - onClick: logout, - }, + onClick: logout + } ]; const breadcrumbItems = [ { title: text.breadcrumb.dashboard }, - { title: pageTitle }, + { title: pageTitle } ]; if (loading) { return ( - + ); } @@ -181,7 +181,7 @@ export default function DashboardPage() { if (!token) { return ( -
+

{text.notLoggedIn}

@@ -190,7 +190,7 @@ export default function DashboardPage() { return ( - + setCollapsed(broken)} - style={{ borderRight: "1px solid #ddd" }} + className={styles.customSider} > -
+
{collapsed ? text.sider.collapsed : text.sider.expanded}
setSelectedKey(e.key)} items={[ - { key: "myinfo", icon: , label: text.sider.menu.myinfo }, - { key: "apikey", icon: , label: text.sider.menu.apikey }, + { + key: "myinfo", + icon: , + label: text.sider.menu.myinfo + }, + { + key: "apikey", + icon: , + label: text.sider.menu.apikey + } ]} /> - - + +
@@ -245,21 +240,15 @@ export default function DashboardPage() { -
+
Hi, {user?.username || "User"}!
- -
+ +
{contentComponent}
From 3ceec7fcc23780c3c37be0b8e2782d3c02e689d7 Mon Sep 17 00:00:00 2001 From: zephyr-sh Date: Sat, 15 Mar 2025 22:34:51 +0800 Subject: [PATCH 23/25] [A] Add API docs page --- .../Dashboard/ApiDocs/ApiUsageExamples.js | 654 ++++++++++++++++++ src/components/Dashboard/ApiDocs/index.js | 15 + .../Dashboard/ApiDocs/index.module.css | 89 +++ src/components/Dashboard/ApiKey/index.js | 48 +- src/pages/dashboard.js | 82 ++- 5 files changed, 820 insertions(+), 68 deletions(-) create mode 100644 src/components/Dashboard/ApiDocs/ApiUsageExamples.js create mode 100644 src/components/Dashboard/ApiDocs/index.js create mode 100644 src/components/Dashboard/ApiDocs/index.module.css diff --git a/src/components/Dashboard/ApiDocs/ApiUsageExamples.js b/src/components/Dashboard/ApiDocs/ApiUsageExamples.js new file mode 100644 index 00000000000..21dc4fc40fd --- /dev/null +++ b/src/components/Dashboard/ApiDocs/ApiUsageExamples.js @@ -0,0 +1,654 @@ +// src/components/Dashboard/ApiDocs/ApiUsageExamples.jsx +import styles from "./index.module.css"; + +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; +import { Card, Collapse, Divider, List, Tabs, Tag, Typography } from "antd"; +import React from "react"; + +const { Panel } = Collapse; +const { Title, Paragraph, Text } = Typography; + +/** i18n 字串 */ +const apiKeyLocale = { + "zh-hant": { + docAlignerTitle: "DocAligner", + docAlignerPath: "POST /docaligner-public-predict", + docAlignerOverview: ` + 這個 API 用於身分證、證件等影像的自動裁切與修正,可選擇是否進行中心裁切(do_center_crop)。 + `, + mrzScannerTitle: "MRZ Scanner", + mrzScannerPath: "POST /mrzscanner-public-predict", + mrzScannerOverview: ` + 這個 API 用於掃描並解析 MRZ 區域,可選擇先對齊影像 (do_doc_align),再決定是否後處理 (do_postprocess)、中心裁切等。 + `, + parameters: "參數說明", + codeExamples: "程式碼範例", + nameLabel: "參數", + typeLabel: "型態", + descLabel: "說明", + requiredLabel: "必填", + defaultLabel: "預設值", + requiredYes: "是", + requiredNo: "否", + docAlignerParams: [ + { + name: "file", + type: "File", + required: true, + default: "-", + desc: "要上傳的影像檔 (jpg, png 等)。" + }, + { + name: "do_center_crop", + type: "bool", + required: false, + default: "false", + desc: "是否進行中心裁切。" + } + ], + mrzScannerParams: [ + { + name: "file", + type: "File", + required: true, + default: "-", + desc: "要上傳的影像檔 (jpg, png 等)。" + }, + { + name: "do_doc_align", + type: "bool", + required: false, + default: "false", + desc: "是否先使用 DocAligner 對齊影像。" + }, + { + name: "do_postprocess", + type: "bool", + required: false, + default: "false", + desc: "是否在辨識後進行後處理 (去雜訊)。" + }, + { + name: "do_center_crop", + type: "bool", + required: false, + default: "true", + desc: "是否在掃描前做中心裁切。" + } + ], + // 語言 + curlLabel: "cURL", + pythonLabel: "Python", + nodeLabel: "Node.js", + jsLabel: "JavaScript", + javaLabel: "Java", + rubyLabel: "Ruby" + }, + en: { + docAlignerTitle: "DocAligner", + docAlignerPath: "POST /docaligner-public-predict", + docAlignerOverview: ` + This API automatically crops and aligns document images (e.g., ID cards). + Optionally apply center cropping (do_center_crop). + `, + mrzScannerTitle: "MRZ Scanner", + mrzScannerPath: "POST /mrzscanner-public-predict", + mrzScannerOverview: ` + This API scans and parses the MRZ zone, optionally aligns the document first (do_doc_align), + then you can decide whether to post-process (do_postprocess) or center-crop. + `, + parameters: "Parameters", + codeExamples: "Code Examples", + nameLabel: "Name", + typeLabel: "Type", + descLabel: "Description", + requiredLabel: "Required", + defaultLabel: "Default", + requiredYes: "Yes", + requiredNo: "No", + docAlignerParams: [ + { + name: "file", + type: "File", + required: true, + default: "-", + desc: "Uploaded image file (jpg, png, etc.)." + }, + { + name: "do_center_crop", + type: "bool", + required: false, + default: "false", + desc: "Whether to apply center cropping." + } + ], + mrzScannerParams: [ + { + name: "file", + type: "File", + required: true, + default: "-", + desc: "Uploaded image file (jpg, png, etc.)." + }, + { + name: "do_doc_align", + type: "bool", + required: false, + default: "false", + desc: "Whether to align the document first." + }, + { + name: "do_postprocess", + type: "bool", + required: false, + default: "false", + desc: "Whether to apply post-processing (noise removal)." + }, + { + name: "do_center_crop", + type: "bool", + required: false, + default: "true", + desc: "Whether to apply center cropping before scanning." + } + ], + curlLabel: "cURL", + pythonLabel: "Python", + nodeLabel: "Node.js", + jsLabel: "JavaScript", + javaLabel: "Java", + rubyLabel: "Ruby" + }, + ja: { + docAlignerTitle: "DocAligner", + docAlignerPath: "POST /docaligner-public-predict", + docAlignerOverview: ` + このAPIはIDカードやパスポートなどの画像を自動トリミング・補正します。 + 必要に応じて中心部分のトリミング(do_center_crop)を行います。 + `, + mrzScannerTitle: "MRZ Scanner", + mrzScannerPath: "POST /mrzscanner-public-predict", + mrzScannerOverview: ` + このAPIはMRZ領域をスキャンして解析します。事前にドキュメントアライメント(do_doc_align)を + 行うことも可能で、後処理(do_postprocess)や中心トリミング(do_center_crop)なども選択できます。 + `, + parameters: "パラメータ", + codeExamples: "コード例", + nameLabel: "名前", + typeLabel: "型", + descLabel: "説明", + requiredLabel: "必須", + defaultLabel: "デフォルト", + requiredYes: "はい", + requiredNo: "いいえ", + docAlignerParams: [ + { + name: "file", + type: "File", + required: true, + default: "-", + desc: "アップロードする画像ファイル(jpg, pngなど)。" + }, + { + name: "do_center_crop", + type: "bool", + required: false, + default: "false", + desc: "中心部分のトリミングを行うかどうか。" + } + ], + mrzScannerParams: [ + { + name: "file", + type: "File", + required: true, + default: "-", + desc: "アップロードする画像ファイル(jpg, pngなど)。" + }, + { + name: "do_doc_align", + type: "bool", + required: false, + default: "false", + desc: "先にDocAlignerで画像を整列させるかどうか。" + }, + { + name: "do_postprocess", + type: "bool", + required: false, + default: "false", + desc: "スキャン後にノイズ除去などの後処理を行うかどうか。" + }, + { + name: "do_center_crop", + type: "bool", + required: false, + default: "true", + desc: "スキャン前に中心部分のトリミングを行うかどうか。" + } + ], + curlLabel: "cURL", + pythonLabel: "Python", + nodeLabel: "Node.js", + jsLabel: "JavaScript", + javaLabel: "Java", + rubyLabel: "Ruby" + } +}; + +/** + * 簡易程式碼區塊 + * 可考慮再加 prismjs 或 highlight.js 作語法上色 + */ +function CodeBlock({ codeStr }) { + const style = { + background: "#f5f5f5", + borderRadius: 4, + padding: 12, + whiteSpace: "pre-wrap", + fontFamily: "Consolas, Menlo, monospace", + marginTop: 8 + }; + return
{codeStr}
; +} + +/** List 方式顯示參數 (模仿 FastAPI docs) */ +function ParamList({ data, text }) { + return ( + ( + + + {item.name}{" "} + {item.type && {item.type}} + {item.required && {text.requiredLabel}} + {item.default && item.default !== "-" && ( + + {text.defaultLabel}: {item.default} + + )} + + } + description={{item.desc}} + /> + + )} + /> + ); +} + + +export default function ApiUsageExamples() { + const { + i18n: { currentLocale } + } = useDocusaurusContext(); + const text = apiKeyLocale[currentLocale] || apiKeyLocale.en; + + /** + * 未來要擴充到 100 個 API,可以在這裡持續增加。 + * 資料結構說明: + * { + * key: unique key (供 Collapse.Panel 用), + * title: 顯示在 Card / Panel 標題, + * route: API 路由 (e.g. POST /xxx), + * overview: API 簡述, + * params: [ { name, type, required, default, desc } ... ], + * codeExamples: [ { label: 'cURL', key: 'curl', children: ... } ... ] + * } + */ + + const apiDefinitions = [ + { + key: "docaligner", + title: text.docAlignerTitle, + route: text.docAlignerPath, + overview: text.docAlignerOverview, + params: text.docAlignerParams, + codeExamples: [ + { + label: text.curlLabel, + key: "curl", + children: ( + " \\ + -F "file=@/path/to/your/document.jpg" \\ + -F "do_center_crop=true" +`} + /> + ) + }, + { + label: text.pythonLabel, + key: "python", + children: ( + "} +files = {"file": open("/path/to/your/document.jpg", "rb")} +data = {"do_center_crop": "true"} + +res = requests.post(url, headers=headers, files=files, data=data) +print(res.json()) +`} + /> + ) + }, + { + label: text.nodeLabel, + key: "node", + children: ( + ' + } +}).then(res => { + console.log(res.data); +}).catch(err => { + console.error(err); +}); +`} + /> + ) + }, + { + label: text.jsLabel, + key: "js", + children: ( + " + }, + body: form +}) + .then(r => r.json()) + .then(data => console.log(data)) + .catch(err => console.error(err)); +`} + /> + ) + }, + { + label: text.javaLabel, + key: "java", + children: ( + ") + .build(); + +try (Response response = client.newCall(request).execute()) { + System.out.println(response.body().string()); +} +`} + /> + ) + }, + { + label: text.rubyLabel, + key: "ruby", + children: ( + + ) + } + ] + }, + { + key: "mrzscanner", + title: text.mrzScannerTitle, + route: text.mrzScannerPath, + overview: text.mrzScannerOverview, + params: text.mrzScannerParams, + codeExamples: [ + { + label: text.curlLabel, + key: "curl", + children: ( + " \\ + -F "file=@/path/to/your/document.jpg" \\ + -F "do_doc_align=true" \\ + -F "do_postprocess=false" \\ + -F "do_center_crop=true" +`} + /> + ) + }, + { + label: text.pythonLabel, + key: "python", + children: ( + "} +files = {"file": open("/path/to/your/document.jpg", "rb")} +data = { + "do_doc_align": "true", + "do_postprocess": "false", + "do_center_crop": "true" +} + +res = requests.post(url, headers=headers, files=files, data=data) +print(res.json()) +`} + /> + ) + }, + { + label: text.nodeLabel, + key: "node", + children: ( + ' + } +}).then(res => { + console.log(res.data); +}).catch(err => { + console.error(err); +}); +`} + /> + ) + }, + { + label: text.jsLabel, + key: "js", + children: ( + " + }, + body: form +}) + .then(r => r.json()) + .then(data => console.log(data)) + .catch(err => console.error(err)); +`} + /> + ) + }, + { + label: text.javaLabel, + key: "java", + children: ( + ") + .build(); + +try (Response response = client.newCall(request).execute()) { + System.out.println(response.body().string()); +} +`} + /> + ) + }, + { + label: text.rubyLabel, + key: "ruby", + children: ( + + ) + } + ] + } + ]; + + return ( + + {apiDefinitions.map((apiDef) => ( + {apiDef.title}} + key={apiDef.key} + // 可在這裡也加上 route + extra={{apiDef.route}} + > + + {apiDef.overview} + + + {text.parameters} + + + + + {text.codeExamples} + + + + + ))} + + ); +} diff --git a/src/components/Dashboard/ApiDocs/index.js b/src/components/Dashboard/ApiDocs/index.js new file mode 100644 index 00000000000..162a77dba19 --- /dev/null +++ b/src/components/Dashboard/ApiDocs/index.js @@ -0,0 +1,15 @@ +// src/components/Dashboard/ApiDocs/index.js +import React from "react"; +import ApiUsageExamples from "./ApiUsageExamples"; +import styles from "./index.module.css"; + +export default function DashboardApiDocs() { + return ( +
+

API Documents

+

這裡是各種 API 的技術文件與使用範例介紹頁。

+ + +
+ ); +} diff --git a/src/components/Dashboard/ApiDocs/index.module.css b/src/components/Dashboard/ApiDocs/index.module.css new file mode 100644 index 00000000000..b88b23c54de --- /dev/null +++ b/src/components/Dashboard/ApiDocs/index.module.css @@ -0,0 +1,89 @@ +/* src/components/Dashboard/ApiDocs/index.module.css */ + +/* 主容器 (類似 apiKeyContainer) */ +.apiDocsContainer { + max-width: 900px; + margin: 0 auto; + padding: 24px; + background-color: #fafafa; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); +} + +/* 頁面標題區域 (若需要) */ +.header { + margin-bottom: 24px; + text-align: left; +} +.header h2 { + font-size: 2rem; + margin: 0 0 8px 0; + color: #333; +} +.header p { + font-size: 1rem; + color: #555; + margin: 0; +} + +/* 折疊面板外框 (Collapse) */ +.collapseRoot { + background-color: #fff; + border: 1px solid #e8e8e8; + border-radius: 8px; + margin-bottom: 24px; +} + +/* 卡片 (API 詳細內容) */ +.apiCard { + background-color: #fff; + border-radius: 8px; + padding: 16px 24px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + transition: transform 0.2s ease, box-shadow 0.2s ease; + margin: 8px 0; +} +.apiCard:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +/* 參數列表 */ +.paramListItem { + padding: 8px 0; + border: none !important; +} +.paramTitle { + margin-bottom: 0; +} +.paramTitle code { + font-size: 0.95rem; + margin-right: 4px; +} +.paramDesc { + font-size: 0.9rem; + color: #444; + margin-left: 4px; +} + +/* 區隔線 */ +.divider { + margin-top: 24px; + margin-bottom: 16px; +} + +/* 程式碼區塊 (可進一步加入 syntax highlight) */ +.codeBlock { + background: #f5f5f5; + border-radius: 4px; + padding: 12px; + white-space: pre-wrap; + font-family: Consolas, Menlo, monospace; + margin-top: 8px; +} + +/* Collapse Panel header 文字 */ +.panelHeader { + font-weight: 600; + color: #333; +} diff --git a/src/components/Dashboard/ApiKey/index.js b/src/components/Dashboard/ApiKey/index.js index dc1e16f7c10..1285fff850b 100644 --- a/src/components/Dashboard/ApiKey/index.js +++ b/src/components/Dashboard/ApiKey/index.js @@ -11,8 +11,6 @@ import React, { useCallback, useEffect, useState } from "react"; import { useAuth } from "../../../context/AuthContext"; import { Tooltip } from "antd"; -import React from "react"; -import ApiUsageExamples from "./ApiUsageExamples"; import CreateTokenModal from "./CreateTokenModal"; import TokenCard from "./TokenCard"; import UsageOverview from "./UsageOverview"; @@ -27,7 +25,7 @@ const apiKeyLocale = { toggleHideTokens: "隱藏全部", createTokenButton: "建立新 Token", collapseHeader: "我的 Token 列表", - apiUsageExampleTitle: "API 使用範例", + // apiUsageExampleTitle: "API 使用範例", // 已移除,交由新頁面負責 newTokenModalTitle: "以下是您的新 Token", newTokenModalDesc: "請複製並保存,關閉後無法再次查看。", copyTokenButton: "複製 Token", @@ -61,7 +59,7 @@ const apiKeyLocale = { toggleHideTokens: "Hide All", createTokenButton: "Create New Token", collapseHeader: "My Token List", - apiUsageExampleTitle: "API Usage Examples", + // apiUsageExampleTitle: "API Usage Examples", newTokenModalTitle: "Your New Token", newTokenModalDesc: "Please copy and save it. It will not be shown again after closing.", copyTokenButton: "Copy Token", @@ -95,7 +93,7 @@ const apiKeyLocale = { toggleHideTokens: "すべて非表示", createTokenButton: "新規トークン作成", collapseHeader: "マイトークン一覧", - apiUsageExampleTitle: "API利用例", + // apiUsageExampleTitle: "API利用例", newTokenModalTitle: "あなたの新しいトークン", newTokenModalDesc: "コピーして保存してください。閉じると再表示されません。", copyTokenButton: "トークンをコピー", @@ -136,7 +134,6 @@ function parseJti(jwtStr) { } } - function CopyTokenButton({ tokenStr, text }) { const [tooltipTitle, setTooltipTitle] = useState(text.copyTokenButton); const [visible, setVisible] = useState(false); @@ -155,7 +152,6 @@ function CopyTokenButton({ tokenStr, text }) { setTooltipTitle(text.copyTokenButton); }, 1000); } catch { - // 若要顯示錯誤,可在此提示 console.error("Failed to copy token"); } }; @@ -199,7 +195,7 @@ export default function DashboardApiKey() { const [loadingProfile, setLoadingProfile] = useState(false); const [apiKeys, setApiKeys] = useState([]); const [userUsage, setUserUsage] = useState(null); - const [usageHistory, setUsageHistory] = useState([]); // 新增:歷史用量資料 + const [usageHistory, setUsageHistory] = useState([]); const [loading, setLoading] = useState(false); const [createModalVisible, setCreateModalVisible] = useState(false); const [newTokenModalVisible, setNewTokenModalVisible] = useState(false); @@ -296,18 +292,17 @@ export default function DashboardApiKey() { // 轉換 UTC+0 時間為使用者當地時區時間 const localHistory = historyData.map((item) => { - const dt = new Date(item.time + "Z"); // 確保時間被解析為 UTC + const dt = new Date(item.time + "Z"); const localTimeStr = dt.toLocaleString(undefined, { - timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, // 取得使用者當前時區 + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", - hour12: false, // 24 小時制 (根據需求調整) + hour12: false, }); - return { ...item, time: localTimeStr, @@ -318,29 +313,22 @@ export default function DashboardApiKey() { } catch (err) { console.error(err); } -}, [userToken]); + }, [userToken]); - // ======================== - // 封裝「刷新用量」函式 - // 同時抓取 user-usage 與 usage-history-minute - // ======================== + // 同時抓取 user-usage 與 usage-history-minute const refreshUsageData = useCallback(async () => { await fetchUserUsage(); await fetchUsageHistory(); }, [fetchUserUsage, fetchUsageHistory]); - // ======================== // 頁面初始載入 - // ======================== useEffect(() => { fetchUserProfile(); fetchTokens(); refreshUsageData(); }, [fetchUserProfile, fetchTokens, refreshUsageData]); - // ======================== // 申請新 Token - // ======================== const handleCreateToken = async (formValues) => { if (!userProfile) return; if (!userProfile.is_email_verified) { @@ -412,9 +400,7 @@ export default function DashboardApiKey() { } }; - // ======================== // Revoke / Remove Token - // ======================== const handleRevokeOrDelete = async (tokenItem) => { if (!userToken) return; @@ -443,9 +429,7 @@ export default function DashboardApiKey() { } }; - // ======================== // 複製 Token(新建後 Modal 使用) - // ======================== const copyToken = async (tokenStr) => { if (!tokenStr) { message.error(text.copyFailure); @@ -469,7 +453,7 @@ export default function DashboardApiKey() { return val.slice(0, 6) + "****" + val.slice(-4); }; - // 顯示計費方案(使用 i18n) + // 顯示計費方案 function getPlanLabel(billingType) { switch (billingType) { case "rate_limit": @@ -490,9 +474,6 @@ export default function DashboardApiKey() { ); }; - // ======================== - // 主體渲染 - // ======================== if (loadingProfile && !userProfile) { return (
@@ -520,8 +501,6 @@ export default function DashboardApiKey() { {/* 顯示方案 & 用量 */} {renderPlanBox()} - - {/* 傳入 usageHistory 使 UsageOverview 顯示更多資訊 */}
+ {/* 只剩一個 Tab:Token 列表 */} ), }, - { - key: "apiUsage", - label: text.apiUsageExampleTitle, - children: , - }, ]} /> @@ -614,7 +589,6 @@ export default function DashboardApiKey() { -
); diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index f14ad56906a..a712259ca31 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -1,11 +1,12 @@ // src/pages/dashboard.js import { + BookOutlined, HomeOutlined, KeyOutlined, MenuFoldOutlined, MenuUnfoldOutlined, PoweroffOutlined, - UserOutlined + UserOutlined, } from "@ant-design/icons"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import Layout from "@theme/Layout"; @@ -19,9 +20,10 @@ import { Row, Spin, theme as antdTheme, - message + message, } from "antd"; import React, { useEffect, useMemo, useState } from "react"; +import DashboardApiDocs from "../components/Dashboard/ApiDocs"; // 引入新的 API Docs 頁面 import DashboardApiKey from "../components/Dashboard/ApiKey"; import DashboardMyInfo from "../components/Dashboard/MyInfo"; import { useAuth } from "../context/AuthContext"; @@ -39,19 +41,21 @@ const localeText = { expanded: "我的後台", menu: { myinfo: "我的資訊", - apikey: "我的 API Key" - } + apikey: "我的 API Key", + apidocs: "API 文件", // 新增 + }, }, breadcrumb: { dashboard: "我的後台", myinfo: "我的資訊", apikey: "我的 API Key", - undefined: "未定義" + apidocs: "API 文件", // 新增 + undefined: "未定義", }, userMenu: { backHome: "回主站", - logout: "登出" - } + logout: "登出", + }, }, en: { dashboardTitle: "Dashboard", @@ -62,19 +66,21 @@ const localeText = { expanded: "My Dashboard", menu: { myinfo: "My Information", - apikey: "My API Key" - } + apikey: "My API Key", + apidocs: "API Docs", + }, }, breadcrumb: { dashboard: "Dashboard", myinfo: "My Information", apikey: "My API Key", - undefined: "Undefined" + apidocs: "API Docs", + undefined: "Undefined", }, userMenu: { backHome: "Back to Site", - logout: "Logout" - } + logout: "Logout", + }, }, ja: { dashboardTitle: "ダッシュボード", @@ -85,25 +91,27 @@ const localeText = { expanded: "マイダッシュボード", menu: { myinfo: "マイ情報", - apikey: "マイAPIキー" - } + apikey: "マイAPIキー", + apidocs: "APIドキュメント", + }, }, breadcrumb: { dashboard: "ダッシュボード", myinfo: "マイ情報", apikey: "マイAPIキー", - undefined: "未定義" + apidocs: "APIドキュメント", + undefined: "未定義", }, userMenu: { backHome: "サイトへ戻る", - logout: "ログアウト" - } - } + logout: "ログアウト", + }, + }, }; export default function DashboardPage() { const { - i18n: { currentLocale } + i18n: { currentLocale }, } = useDocusaurusContext(); const text = localeText[currentLocale] || localeText.en; const { token, user, loading, logout } = useAuth(); @@ -114,27 +122,33 @@ export default function DashboardPage() { useEffect(() => { if (!loading && !token) { message.warning(text.loginWarning); - // 可根據需求導向登入頁面,例如:window.location.href = "/"; + // 可根據需求導向登入頁面 } }, [loading, token, text.loginWarning]); + // 依選單項目切換對應內容 const contentComponent = useMemo(() => { switch (selectedKey) { case "myinfo": return ; case "apikey": return ; + case "apidocs": + return ; default: return null; } }, [selectedKey]); + // 面包屑標題 const pageTitle = useMemo(() => { switch (selectedKey) { case "myinfo": return text.breadcrumb.myinfo; case "apikey": return text.breadcrumb.apikey; + case "apidocs": + return text.breadcrumb.apidocs; default: return text.breadcrumb.undefined; } @@ -155,19 +169,19 @@ export default function DashboardPage() { label: text.userMenu.backHome, onClick: () => { window.location.href = homePath; - } + }, }, { key: "logout", icon: , label: text.userMenu.logout, - onClick: logout - } + onClick: logout, + }, ]; const breadcrumbItems = [ { title: text.breadcrumb.dashboard }, - { title: pageTitle } + { title: pageTitle }, ]; if (loading) { @@ -211,18 +225,26 @@ export default function DashboardPage() { { key: "myinfo", icon: , - label: text.sider.menu.myinfo + label: text.sider.menu.myinfo, }, { key: "apikey", icon: , - label: text.sider.menu.apikey - } + label: text.sider.menu.apikey, + }, + { + key: "apidocs", + icon: , + label: text.sider.menu.apidocs, + }, ]} /> - +
@@ -248,9 +270,7 @@ export default function DashboardPage() { -
- {contentComponent} -
+
{contentComponent}
From 047ce26d6cf3fffde1a994cc22a1ac8f61f0ec21 Mon Sep 17 00:00:00 2001 From: zephyr-sh Date: Sat, 15 Mar 2025 23:13:24 +0800 Subject: [PATCH 24/25] [C] Update github workflow --- .github/workflows/test-deploy.yml | 12 +- src/utils/mockApi.js | 586 ------------------------------ 2 files changed, 7 insertions(+), 591 deletions(-) delete mode 100644 src/utils/mockApi.js diff --git a/.github/workflows/test-deploy.yml b/.github/workflows/test-deploy.yml index 5e188901889..db457fc7885 100644 --- a/.github/workflows/test-deploy.yml +++ b/.github/workflows/test-deploy.yml @@ -10,15 +10,17 @@ on: jobs: test-deploy: name: Test deployment - runs-on: ubuntu-latest + runs-on: [self-hosted, unicorn] steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 with: node-version: 22 cache: yarn - name: Install dependencies run: yarn install - - name: Test build website - run: yarn build + - name: Build website + run: DOCUSAURUS_IGNORE_SSG_WARNINGS=true yarn build diff --git a/src/utils/mockApi.js b/src/utils/mockApi.js deleted file mode 100644 index f438d175eda..00000000000 --- a/src/utils/mockApi.js +++ /dev/null @@ -1,586 +0,0 @@ -// 檔案: src/components/dashboard/DashboardMyComments.jsx - -import { - Button, - Card, - Col, - DatePicker, - Form, - Input, - message, - Modal, - Row, - Space, - Table, -} from "antd"; -import moment from "moment"; -import React, { useEffect, useState } from "react"; -import { - deleteCommentApi, - getMyCommentsApi, - updateCommentApi, -} from "../../../utils/mockApi"; - -const { RangePicker } = DatePicker; - -export default function DashboardMyComments() { - const [loading, setLoading] = useState(false); - const [comments, setComments] = useState([]); - - // 用來控制「編輯留言」的彈窗 - const [editingComment, setEditingComment] = useState(null); - const [editModalVisible, setEditModalVisible] = useState(false); - - // Table 搜尋功能 - const [searchText, setSearchText] = useState(""); - - // 批次刪除(row selection) - const [selectedRowKeys, setSelectedRowKeys] = useState([]); - - // 建立日期篩選 - const [dateRange, setDateRange] = useState([]); // [moment|null, moment|null] - - useEffect(() => { - fetchComments(); - }, []); - - const fetchComments = async () => { - setLoading(true); - try { - const data = await getMyCommentsApi(); - setComments(data); - } catch (err) { - message.error(err.message || "取得留言失敗"); - } finally { - setLoading(false); - } - }; - - // 「編輯」按鈕 → 打開 Modal - const handleEdit = (record) => { - setEditingComment({ ...record }); - setEditModalVisible(true); - }; - - // 「刪除」按鈕(單筆) - const handleDelete = async (id) => { - try { - await deleteCommentApi(id); - message.success("留言已刪除"); - setComments((prev) => prev.filter((c) => c.id !== id)); - } catch (err) { - message.error(err.message || "刪除失敗"); - } - }; - - // 「儲存」編輯後的結果 - const handleSaveComment = async (values) => { - try { - await updateCommentApi(values.id, values.content); - message.success("留言已更新"); - setComments((prev) => - prev.map((c) => (c.id === values.id ? { ...c, ...values } : c)) - ); - setEditModalVisible(false); - } catch (err) { - message.error(err.message || "更新失敗"); - } - }; - - // 批次刪除 - const handleBatchDelete = async () => { - if (selectedRowKeys.length === 0) return; - try { - for (let commentId of selectedRowKeys) { - await deleteCommentApi(commentId); - } - message.success("批次刪除成功"); - setComments((prev) => prev.filter((c) => !selectedRowKeys.includes(c.id))); - setSelectedRowKeys([]); // 清空選取 - } catch (err) { - message.error("批次刪除失敗:" + err.message); - } - }; - - // Table columns - const columns = [ - { title: "ID", dataIndex: "id", width: 80 }, - { - title: "留言內容", - dataIndex: "content", - sorter: (a, b) => a.content.localeCompare(b.content), - render: (text) => {text}, - }, - { - title: "建立日期", - dataIndex: "createdAt", - width: 180, - sorter: (a, b) => - new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), - }, - { - title: "操作", - width: 120, - render: (_, record) => ( - <> - - - - ), - }, - ]; - - // 給 Table 的 rowSelection - const rowSelection = { - selectedRowKeys, - onChange: (newSelectedRowKeys) => { - setSelectedRowKeys(newSelectedRowKeys); - }, - }; - - // 前端資料篩選:根據「留言內容」與「建立日期區間」 - const filteredComments = comments.filter((c) => { - // 1. 關鍵字篩選 - const matchSearch = c.content - .toLowerCase() - .includes(searchText.toLowerCase()); - - // 2. 日期區間篩選 - let matchDate = true; - if (dateRange && dateRange.length === 2 && dateRange[0] && dateRange[1]) { - const start = dateRange[0].startOf("day"); - const end = dateRange[1].endOf("day"); - const created = moment(c.createdAt, "YYYY-MM-DD HH:mm"); - matchDate = created.isBetween(start, end, null, "[]"); - } - - return matchSearch && matchDate; - }); - - return ( - -

我的留言

- - {/* 上方操作列:搜尋、日期篩選、批次刪除 */} - -
- setSearchText(e.target.value)} - /> - - - - - setDateRange(dates || [])} - /> - - - - - -
- - {/* 編輯留言 Modal */} - setEditModalVisible(false)} - onSave={handleSaveComment} - /> - - ); -} - -/** 編輯留言 Modal */ -function EditCommentModal({ visible, onCancel, comment, onSave }) { - const [form] = Form.useForm(); - - useEffect(() => { - if (comment) { - form.setFieldsValue(comment); - } else { - form.resetFields(); - } - }, [comment, form]); - - const onFinish = (values) => { - onSave(values); - }; - - return ( - form.submit()} - okText="儲存" - cancelText="取消" - destroyOnClose - > -
- - - - - -
- ); -} - -export async function loginApi(username, password) { - return new Promise((resolve, reject) => { - setTimeout(() => { - if (!username || !password) { - reject(new Error("登入失敗,請輸入帳號與密碼")); - } else { - if (username === "admin" && password === "admin123") { - resolve({ token: "fake-admin-token" }); - } else { - resolve({ token: "fake-jwt-token" }); - } - } - }, 500); - }); -} - -export async function registerApi(username, password) { - return new Promise((resolve, reject) => { - setTimeout(() => { - if (!username || !password) { - reject(new Error("註冊失敗,請輸入帳號與密碼")); - } else { - resolve({ token: "fake-register-token" }); - } - }, 500); - }); -} - -export async function socialLoginApi(provider) { - return new Promise((resolve, reject) => { - setTimeout(() => { - if (provider === "Google" || provider === "Facebook") { - resolve({ token: `fake-${provider.toLowerCase()}-token` }); - } else { - reject(new Error("不支援此社群登入方式")); - } - }, 500); - }); -} - -export async function forgotPasswordApi(email) { - return new Promise((resolve, reject) => { - setTimeout(() => { - if (!email) { - reject(new Error("請輸入 Email")); - } else if (email.includes("@")) { - resolve(true); - } else { - reject(new Error("Email 格式不正確,或該 Email 不存在於系統")); - } - }, 500); - }); -} - -export async function getUserInfo(token) { - return new Promise((resolve, reject) => { - setTimeout(() => { - if (!token) { - reject(new Error("無效的 token")); - } else { - // 假裝回傳使用者資訊 - resolve({ - id: 1, - name: "Mock User", - email: "mock@example.com", - is_email_verified: false, - avatar: null, - }); - } - }, 500); - }); -} - -export async function updateProfileApi(token, profileData) { - return new Promise((resolve, reject) => { - setTimeout(() => { - if (!token) { - return reject(new Error("尚未登入,無法更新")); - } - resolve(true); - }, 500); - }); -} - -export async function updatePasswordApi(token, oldPassword, newPassword) { - return new Promise((resolve, reject) => { - setTimeout(() => { - if (!token) { - return reject(new Error("尚未登入")); - } - if (!oldPassword || !newPassword) { - return reject(new Error("密碼資料不完整")); - } - resolve(true); - }, 500); - }); -} - -export async function uploadAvatarApi(token, file) { - return new Promise((resolve, reject) => { - setTimeout(() => { - if (!token) { - return reject(new Error("尚未登入")); - } - resolve("https://via.placeholder.com/100?text=New+Avatar"); - }, 500); - }); -} - -export async function resendVerificationEmailApi(token) { - return new Promise((resolve, reject) => { - setTimeout(() => { - if (!token) { - return reject(new Error("尚未登入")); - } - resolve(true); - }, 500); - }); -} - -export async function deleteAccountApi(token) { - return new Promise((resolve, reject) => { - setTimeout(() => { - if (!token) { - return reject(new Error("尚未登入")); - } - resolve(true); - }, 500); - }); -} - -/* ========== 留言相關 ========== */ -export async function getMyCommentsApi() { - return new Promise((resolve) => { - setTimeout(() => { - resolve([ - { id: 101, content: "Great post!", createdAt: "2023-01-01 10:20" }, - { id: 102, content: "Nice article.", createdAt: "2023-02-02 15:10" }, - ]); - }, 500); - }); -} - -export async function updateCommentApi(commentId, newContent) { - return new Promise((resolve, reject) => { - setTimeout(() => { - if (!commentId || !newContent.trim()) { - return reject(new Error("更新留言失敗:留言不可空白")); - } - resolve(true); - }, 500); - }); -} - -export async function deleteCommentApi(commentId) { - return new Promise((resolve, reject) => { - setTimeout(() => { - if (!commentId) { - return reject(new Error("無法刪除:缺少留言 ID")); - } - resolve(true); - }, 500); - }); -} - -/* ========== API Key (單把) ========== - * 你先前的程式只操作單把 Key, 這裡保留原本的 getMyApiKeyApi, regenerateApiKeyApi - * 但對應你最新版的程式需要多把 Key, 故以下是「多把Key」的實作。 - * 如果你確定只需要『單把 Key』邏輯,可在前端程式中刪除對多把Key的使用。 - */ -export async function getMyApiKeyApi() { - return new Promise((resolve) => { - setTimeout(() => { - resolve("fake-single-api-key-123456789"); - }, 500); - }); -} -export async function regenerateApiKeyApi(idOrNothing) { - return new Promise((resolve) => { - setTimeout(() => { - const newKey = - "fake-key-" + Math.random().toString(36).slice(2, 8); - resolve(newKey); - }, 500); - }); -} - -/* ========== API Key (多把) ========== */ -/** 假裝後端維護多把 Key 的資料 */ -let FAKE_API_KEYS = [ - { - id: "k1", - name: "Default Key", - keyString: "fake-multi-key-11111111", - createdAt: "2023-01-10T09:00:00Z", - expireAt: "", // 空字串代表無到期 - status: "active", // active | expired | disabled - }, - { - id: "k2", - name: "Staging Env Key", - keyString: "fake-multi-key-22222222", - createdAt: "2023-02-15T15:30:00Z", - expireAt: "2024-01-01T00:00:00Z", - status: "active", - }, -]; - -/** 取得多把 key */ -export async function getMyApiKeysApi() { - return new Promise((resolve, reject) => { - setTimeout(() => { - // 假設已登入 - resolve([...FAKE_API_KEYS]); - }, 500); - }); -} - -/** 新增一把 key */ -export async function createApiKeyApi(name) { - return new Promise((resolve, reject) => { - setTimeout(() => { - if (!name.trim()) { - return reject(new Error("名稱不可空白")); - } - const newId = `k${Math.floor(Math.random() * 10000)}`; - const newKeyStr = "fake-multi-key-" + Math.random().toString(36).slice(2, 8); - const now = new Date().toISOString(); - const newObj = { - id: newId, - name: name.trim(), - keyString: newKeyStr, - createdAt: now, - expireAt: "", - status: "active", - }; - FAKE_API_KEYS.push(newObj); - resolve(newObj); - }, 500); - }); -} - -/** 刪除某把 key */ -export async function deleteApiKeyApi(keyId) { - return new Promise((resolve, reject) => { - setTimeout(() => { - FAKE_API_KEYS = FAKE_API_KEYS.filter((k) => k.id !== keyId); - resolve(true); - }, 500); - }); -} - -/** 更新 key 名稱 */ -export async function updateApiKeyNameApi(keyId, newName) { - return new Promise((resolve, reject) => { - setTimeout(() => { - if (!newName.trim()) { - return reject(new Error("名稱不可空白")); - } - FAKE_API_KEYS = FAKE_API_KEYS.map((k) => - k.id === keyId ? { ...k, name: newName.trim() } : k - ); - resolve(true); - }, 500); - }); -} - -/** 查詢 API Key 用量 (展示在 Drawer) */ -export async function getApiKeyUsageApi(keyId) { - return new Promise((resolve, reject) => { - setTimeout(() => { - // 假裝回傳該 Key 的用量、白名單 etc. - const findKey = FAKE_API_KEYS.find((k) => k.id === keyId); - if (!findKey) { - return reject(new Error("找不到此 API Key")); - } - // 自訂 usage, limit, ipWhitelist... - resolve({ - usage: Math.floor(Math.random() * 500), // 已使用 0~499 - limit: 500, - ipWhitelist: ["192.168.0.1", "127.0.0.1"], - }); - }, 500); - }); -} - -/* ========== API Usage ========== */ -/** 後端回傳使用紀錄列表 (僅範例) */ -export async function getApiUsageApi() { - return new Promise((resolve) => { - setTimeout(() => { - resolve([ - { - id: 1, - timestamp: "2023-03-01 09:15:00", - endpoint: "/v1/some-api", - statusCode: 200, - latency: 123, - }, - { - id: 2, - timestamp: "2023-03-01 09:16:10", - endpoint: "/v1/some-api", - statusCode: 400, - latency: 45, - }, - ]); - }, 500); - }); -} - -/* ========== 其他(若有) ========== */ -/** e.g. createCommentApi (只留範例) */ -export async function createCommentApi(content) { - return new Promise((resolve, reject) => { - setTimeout(() => { - if (!content) { - return reject(new Error("留言內容不可為空")); - } - resolve({ - id: Math.floor(Math.random() * 100000), - content, - createdAt: "2023-08-01 12:00", - }); - }, 500); - }); -} \ No newline at end of file From f4c298c2aa84b3127683e8e90ae54acb6197e230 Mon Sep 17 00:00:00 2001 From: zephyr-sh Date: Sun, 16 Mar 2025 07:41:28 +0800 Subject: [PATCH 25/25] [C] Update ApiDocs --- .../Dashboard/ApiDocs/ApiUsageExamples.js | 648 +----------------- src/components/Dashboard/ApiDocs/CodeBlock.js | 55 ++ src/components/Dashboard/ApiDocs/ParamList.js | 38 + .../Dashboard/ApiDocs/apis/DocAligner.js | 261 +++++++ .../Dashboard/ApiDocs/apis/MrzScanner.js | 317 +++++++++ src/components/Dashboard/ApiDocs/index.js | 89 ++- .../Dashboard/ApiDocs/index.module.css | 134 +++- src/pages/dashboard.js | 13 +- 8 files changed, 893 insertions(+), 662 deletions(-) create mode 100644 src/components/Dashboard/ApiDocs/CodeBlock.js create mode 100644 src/components/Dashboard/ApiDocs/ParamList.js create mode 100644 src/components/Dashboard/ApiDocs/apis/DocAligner.js create mode 100644 src/components/Dashboard/ApiDocs/apis/MrzScanner.js diff --git a/src/components/Dashboard/ApiDocs/ApiUsageExamples.js b/src/components/Dashboard/ApiDocs/ApiUsageExamples.js index 21dc4fc40fd..116724474ec 100644 --- a/src/components/Dashboard/ApiDocs/ApiUsageExamples.js +++ b/src/components/Dashboard/ApiDocs/ApiUsageExamples.js @@ -1,651 +1,45 @@ // src/components/Dashboard/ApiDocs/ApiUsageExamples.jsx -import styles from "./index.module.css"; - import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; -import { Card, Collapse, Divider, List, Tabs, Tag, Typography } from "antd"; +import { Card, Collapse, Divider, Tabs, Tag, Typography } from "antd"; import React from "react"; +import DocAligner from "./apis/DocAligner"; +import MrzScanner from "./apis/MrzScanner"; +import styles from "./index.module.css"; // 確保引入新的 CSS +import ParamList from "./ParamList"; const { Panel } = Collapse; -const { Title, Paragraph, Text } = Typography; - -/** i18n 字串 */ -const apiKeyLocale = { - "zh-hant": { - docAlignerTitle: "DocAligner", - docAlignerPath: "POST /docaligner-public-predict", - docAlignerOverview: ` - 這個 API 用於身分證、證件等影像的自動裁切與修正,可選擇是否進行中心裁切(do_center_crop)。 - `, - mrzScannerTitle: "MRZ Scanner", - mrzScannerPath: "POST /mrzscanner-public-predict", - mrzScannerOverview: ` - 這個 API 用於掃描並解析 MRZ 區域,可選擇先對齊影像 (do_doc_align),再決定是否後處理 (do_postprocess)、中心裁切等。 - `, - parameters: "參數說明", - codeExamples: "程式碼範例", - nameLabel: "參數", - typeLabel: "型態", - descLabel: "說明", - requiredLabel: "必填", - defaultLabel: "預設值", - requiredYes: "是", - requiredNo: "否", - docAlignerParams: [ - { - name: "file", - type: "File", - required: true, - default: "-", - desc: "要上傳的影像檔 (jpg, png 等)。" - }, - { - name: "do_center_crop", - type: "bool", - required: false, - default: "false", - desc: "是否進行中心裁切。" - } - ], - mrzScannerParams: [ - { - name: "file", - type: "File", - required: true, - default: "-", - desc: "要上傳的影像檔 (jpg, png 等)。" - }, - { - name: "do_doc_align", - type: "bool", - required: false, - default: "false", - desc: "是否先使用 DocAligner 對齊影像。" - }, - { - name: "do_postprocess", - type: "bool", - required: false, - default: "false", - desc: "是否在辨識後進行後處理 (去雜訊)。" - }, - { - name: "do_center_crop", - type: "bool", - required: false, - default: "true", - desc: "是否在掃描前做中心裁切。" - } - ], - // 語言 - curlLabel: "cURL", - pythonLabel: "Python", - nodeLabel: "Node.js", - jsLabel: "JavaScript", - javaLabel: "Java", - rubyLabel: "Ruby" - }, - en: { - docAlignerTitle: "DocAligner", - docAlignerPath: "POST /docaligner-public-predict", - docAlignerOverview: ` - This API automatically crops and aligns document images (e.g., ID cards). - Optionally apply center cropping (do_center_crop). - `, - mrzScannerTitle: "MRZ Scanner", - mrzScannerPath: "POST /mrzscanner-public-predict", - mrzScannerOverview: ` - This API scans and parses the MRZ zone, optionally aligns the document first (do_doc_align), - then you can decide whether to post-process (do_postprocess) or center-crop. - `, - parameters: "Parameters", - codeExamples: "Code Examples", - nameLabel: "Name", - typeLabel: "Type", - descLabel: "Description", - requiredLabel: "Required", - defaultLabel: "Default", - requiredYes: "Yes", - requiredNo: "No", - docAlignerParams: [ - { - name: "file", - type: "File", - required: true, - default: "-", - desc: "Uploaded image file (jpg, png, etc.)." - }, - { - name: "do_center_crop", - type: "bool", - required: false, - default: "false", - desc: "Whether to apply center cropping." - } - ], - mrzScannerParams: [ - { - name: "file", - type: "File", - required: true, - default: "-", - desc: "Uploaded image file (jpg, png, etc.)." - }, - { - name: "do_doc_align", - type: "bool", - required: false, - default: "false", - desc: "Whether to align the document first." - }, - { - name: "do_postprocess", - type: "bool", - required: false, - default: "false", - desc: "Whether to apply post-processing (noise removal)." - }, - { - name: "do_center_crop", - type: "bool", - required: false, - default: "true", - desc: "Whether to apply center cropping before scanning." - } - ], - curlLabel: "cURL", - pythonLabel: "Python", - nodeLabel: "Node.js", - jsLabel: "JavaScript", - javaLabel: "Java", - rubyLabel: "Ruby" - }, - ja: { - docAlignerTitle: "DocAligner", - docAlignerPath: "POST /docaligner-public-predict", - docAlignerOverview: ` - このAPIはIDカードやパスポートなどの画像を自動トリミング・補正します。 - 必要に応じて中心部分のトリミング(do_center_crop)を行います。 - `, - mrzScannerTitle: "MRZ Scanner", - mrzScannerPath: "POST /mrzscanner-public-predict", - mrzScannerOverview: ` - このAPIはMRZ領域をスキャンして解析します。事前にドキュメントアライメント(do_doc_align)を - 行うことも可能で、後処理(do_postprocess)や中心トリミング(do_center_crop)なども選択できます。 - `, - parameters: "パラメータ", - codeExamples: "コード例", - nameLabel: "名前", - typeLabel: "型", - descLabel: "説明", - requiredLabel: "必須", - defaultLabel: "デフォルト", - requiredYes: "はい", - requiredNo: "いいえ", - docAlignerParams: [ - { - name: "file", - type: "File", - required: true, - default: "-", - desc: "アップロードする画像ファイル(jpg, pngなど)。" - }, - { - name: "do_center_crop", - type: "bool", - required: false, - default: "false", - desc: "中心部分のトリミングを行うかどうか。" - } - ], - mrzScannerParams: [ - { - name: "file", - type: "File", - required: true, - default: "-", - desc: "アップロードする画像ファイル(jpg, pngなど)。" - }, - { - name: "do_doc_align", - type: "bool", - required: false, - default: "false", - desc: "先にDocAlignerで画像を整列させるかどうか。" - }, - { - name: "do_postprocess", - type: "bool", - required: false, - default: "false", - desc: "スキャン後にノイズ除去などの後処理を行うかどうか。" - }, - { - name: "do_center_crop", - type: "bool", - required: false, - default: "true", - desc: "スキャン前に中心部分のトリミングを行うかどうか。" - } - ], - curlLabel: "cURL", - pythonLabel: "Python", - nodeLabel: "Node.js", - jsLabel: "JavaScript", - javaLabel: "Java", - rubyLabel: "Ruby" - } -}; - -/** - * 簡易程式碼區塊 - * 可考慮再加 prismjs 或 highlight.js 作語法上色 - */ -function CodeBlock({ codeStr }) { - const style = { - background: "#f5f5f5", - borderRadius: 4, - padding: 12, - whiteSpace: "pre-wrap", - fontFamily: "Consolas, Menlo, monospace", - marginTop: 8 - }; - return
{codeStr}
; -} - -/** List 方式顯示參數 (模仿 FastAPI docs) */ -function ParamList({ data, text }) { - return ( - ( - - - {item.name}{" "} - {item.type && {item.type}} - {item.required && {text.requiredLabel}} - {item.default && item.default !== "-" && ( - - {text.defaultLabel}: {item.default} - - )} - - } - description={{item.desc}} - /> - - )} - /> - ); -} - +const { Paragraph, Text } = Typography; export default function ApiUsageExamples() { const { - i18n: { currentLocale } + i18n: { currentLocale }, } = useDocusaurusContext(); - const text = apiKeyLocale[currentLocale] || apiKeyLocale.en; - - /** - * 未來要擴充到 100 個 API,可以在這裡持續增加。 - * 資料結構說明: - * { - * key: unique key (供 Collapse.Panel 用), - * title: 顯示在 Card / Panel 標題, - * route: API 路由 (e.g. POST /xxx), - * overview: API 簡述, - * params: [ { name, type, required, default, desc } ... ], - * codeExamples: [ { label: 'cURL', key: 'curl', children: ... } ... ] - * } - */ - - const apiDefinitions = [ - { - key: "docaligner", - title: text.docAlignerTitle, - route: text.docAlignerPath, - overview: text.docAlignerOverview, - params: text.docAlignerParams, - codeExamples: [ - { - label: text.curlLabel, - key: "curl", - children: ( - " \\ - -F "file=@/path/to/your/document.jpg" \\ - -F "do_center_crop=true" -`} - /> - ) - }, - { - label: text.pythonLabel, - key: "python", - children: ( - "} -files = {"file": open("/path/to/your/document.jpg", "rb")} -data = {"do_center_crop": "true"} - -res = requests.post(url, headers=headers, files=files, data=data) -print(res.json()) -`} - /> - ) - }, - { - label: text.nodeLabel, - key: "node", - children: ( - ' - } -}).then(res => { - console.log(res.data); -}).catch(err => { - console.error(err); -}); -`} - /> - ) - }, - { - label: text.jsLabel, - key: "js", - children: ( - " - }, - body: form -}) - .then(r => r.json()) - .then(data => console.log(data)) - .catch(err => console.error(err)); -`} - /> - ) - }, - { - label: text.javaLabel, - key: "java", - children: ( - ") - .build(); - -try (Response response = client.newCall(request).execute()) { - System.out.println(response.body().string()); -} -`} - /> - ) - }, - { - label: text.rubyLabel, - key: "ruby", - children: ( - - ) - } - ] - }, - { - key: "mrzscanner", - title: text.mrzScannerTitle, - route: text.mrzScannerPath, - overview: text.mrzScannerOverview, - params: text.mrzScannerParams, - codeExamples: [ - { - label: text.curlLabel, - key: "curl", - children: ( - " \\ - -F "file=@/path/to/your/document.jpg" \\ - -F "do_doc_align=true" \\ - -F "do_postprocess=false" \\ - -F "do_center_crop=true" -`} - /> - ) - }, - { - label: text.pythonLabel, - key: "python", - children: ( - "} -files = {"file": open("/path/to/your/document.jpg", "rb")} -data = { - "do_doc_align": "true", - "do_postprocess": "false", - "do_center_crop": "true" -} - -res = requests.post(url, headers=headers, files=files, data=data) -print(res.json()) -`} - /> - ) - }, - { - label: text.nodeLabel, - key: "node", - children: ( - ' - } -}).then(res => { - console.log(res.data); -}).catch(err => { - console.error(err); -}); -`} - /> - ) - }, - { - label: text.jsLabel, - key: "js", - children: ( - " - }, - body: form -}) - .then(r => r.json()) - .then(data => console.log(data)) - .catch(err => console.error(err)); -`} - /> - ) - }, - { - label: text.javaLabel, - key: "java", - children: ( - ") - .build(); - -try (Response response = client.newCall(request).execute()) { - System.out.println(response.body().string()); -} -`} - /> - ) - }, - { - label: text.rubyLabel, - key: "ruby", - children: ( - - ) - } - ] - } - ]; + // 從各個 API 檔案根據當前語系取得定義 + const apiDefinitions = [DocAligner(currentLocale), MrzScanner(currentLocale)]; return ( - + {apiDefinitions.map((apiDef) => ( {apiDef.title}} key={apiDef.key} - // 可在這裡也加上 route - extra={{apiDef.route}} + header={ +
+ {apiDef.title} + {apiDef.route} +
+ } > - + {apiDef.overview} - - {text.parameters} + {apiDef.text.parameters} - - + - {text.codeExamples} + {apiDef.text.codeExamples} - +
))} diff --git a/src/components/Dashboard/ApiDocs/CodeBlock.js b/src/components/Dashboard/ApiDocs/CodeBlock.js new file mode 100644 index 00000000000..d071d34e845 --- /dev/null +++ b/src/components/Dashboard/ApiDocs/CodeBlock.js @@ -0,0 +1,55 @@ +// src/components/Dashboard/ApiDocs/CodeBlock.jsx +import { CopyOutlined } from "@ant-design/icons"; +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; +import { Button, Tooltip } from "antd"; +import React, { useState } from "react"; +import styles from "./index.module.css"; + +const i18n = { + "zh-hant": { + copy: "複製程式碼", + copied: "已複製", + }, + en: { + copy: "Copy Code", + copied: "Copied", + }, + ja: { + copy: "コードをコピー", + copied: "コピー済み", + }, +}; + +export default function CodeBlock({ codeStr }) { + const { + i18n: { currentLocale }, + } = useDocusaurusContext(); + const localeText = i18n[currentLocale] || i18n.en; + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(codeStr); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error("複製失敗", error); + } + }; + + return ( +
+ +
+ ); +} diff --git a/src/components/Dashboard/ApiDocs/ParamList.js b/src/components/Dashboard/ApiDocs/ParamList.js new file mode 100644 index 00000000000..fe7c6128ae4 --- /dev/null +++ b/src/components/Dashboard/ApiDocs/ParamList.js @@ -0,0 +1,38 @@ +// src/components/Dashboard/ApiDocs/ParamList.jsx +import { List, Tag, Typography } from "antd"; +import React from "react"; +import styles from "./index.module.css"; + +const { Text } = Typography; + +export default function ParamList({ data, text }) { + return ( + { + const { name, type, required, default: defaultVal, desc } = item; + return ( + + + {name}{" "} + {type && {type}} + {required && {text.requiredLabel}} + {defaultVal && defaultVal !== "-" && ( + + {text.defaultLabel}: {defaultVal} + + )} + + } + description={{desc}} + /> + + ); + }} + /> + ); +} diff --git a/src/components/Dashboard/ApiDocs/apis/DocAligner.js b/src/components/Dashboard/ApiDocs/apis/DocAligner.js new file mode 100644 index 00000000000..1413375c83a --- /dev/null +++ b/src/components/Dashboard/ApiDocs/apis/DocAligner.js @@ -0,0 +1,261 @@ +// src/components/Dashboard/ApiDocs/apis/DocAligner.jsx +import React from "react"; +import CodeBlock from "../CodeBlock"; + +const DocAlignerI18n = { + "zh-hant": { + docAlignerTitle: "DocAligner", + docAlignerPath: "POST /docaligner-public-predict", + docAlignerOverview: ` + 這個 API 用於身分證、證件等影像的自動裁切與修正,可選擇是否進行中心裁切(do_center_crop)。 + `, + parameters: "參數說明", + codeExamples: "程式碼範例", + requiredLabel: "必填", + defaultLabel: "預設值", + curlLabel: "cURL", + pythonLabel: "Python", + nodeLabel: "Node.js", + jsLabel: "JavaScript", + javaLabel: "Java", + rubyLabel: "Ruby", + docAlignerParams: [ + { + name: "file", + type: "File", + required: true, + default: "-", + desc: "要上傳的影像檔 (jpg, png 等)。" + }, + { + name: "do_center_crop", + type: "bool", + required: false, + default: "false", + desc: "是否進行中心裁切。" + } + ] + }, + en: { + docAlignerTitle: "DocAligner", + docAlignerPath: "POST /docaligner-public-predict", + docAlignerOverview: ` + This API automatically crops and aligns document images (e.g., ID cards). + Optionally apply center cropping (do_center_crop). + `, + parameters: "Parameters", + codeExamples: "Code Examples", + requiredLabel: "Required", + defaultLabel: "Default", + curlLabel: "cURL", + pythonLabel: "Python", + nodeLabel: "Node.js", + jsLabel: "JavaScript", + javaLabel: "Java", + rubyLabel: "Ruby", + docAlignerParams: [ + { + name: "file", + type: "File", + required: true, + default: "-", + desc: "Uploaded image file (jpg, png, etc.)." + }, + { + name: "do_center_crop", + type: "bool", + required: false, + default: "false", + desc: "Whether to apply center cropping." + } + ] + }, + ja: { + docAlignerTitle: "DocAligner", + docAlignerPath: "POST /docaligner-public-predict", + docAlignerOverview: ` + このAPIはIDカードやパスポートなどの画像を自動トリミング・補正します。 + 必要に応じて中心部分のトリミング(do_center_crop)を行います。 + `, + parameters: "パラメータ", + codeExamples: "コード例", + requiredLabel: "必須", + defaultLabel: "デフォルト", + curlLabel: "cURL", + pythonLabel: "Python", + nodeLabel: "Node.js", + jsLabel: "JavaScript", + javaLabel: "Java", + rubyLabel: "Ruby", + docAlignerParams: [ + { + name: "file", + type: "File", + required: true, + default: "-", + desc: "アップロードする画像ファイル(jpg, pngなど)。" + }, + { + name: "do_center_crop", + type: "bool", + required: false, + default: "false", + desc: "中心部分のトリミングを行うかどうか。" + } + ] + } +}; + +export default function DocAligner(locale = "en") { + const text = DocAlignerI18n[locale] || DocAlignerI18n.en; + return { + key: "docaligner", + text, + title: text.docAlignerTitle, + route: text.docAlignerPath, + overview: text.docAlignerOverview, + params: text.docAlignerParams, + codeExamples: [ + { + label: text.curlLabel, + key: "curl", + children: ( + " \\ + -F "file=@/path/to/your/document.jpg" \\ + -F "do_center_crop=true" +`} + /> + ) + }, + { + label: text.pythonLabel, + key: "python", + children: ( + "} +files = {"file": open("/path/to/your/document.jpg", "rb")} +data = {"do_center_crop": "true"} + +res = requests.post(url, headers=headers, files=files, data=data) +print(res.json()) +`} + /> + ) + }, + { + label: text.nodeLabel, + key: "node", + children: ( + ' + } +}).then(res => { + console.log(res.data); +}).catch(err => { + console.error(err); +}); +`} + /> + ) + }, + { + label: text.jsLabel, + key: "js", + children: ( + " + }, + body: form +}) + .then(r => r.json()) + .then(data => console.log(data)) + .catch(err => console.error(err)); +`} + /> + ) + }, + { + label: text.javaLabel, + key: "java", + children: ( + ") + .build(); + +try (Response response = client.newCall(request).execute()) { + System.out.println(response.body().string()); +} +`} + /> + ) + }, + { + label: text.rubyLabel, + key: "ruby", + children: ( + + ) + } + ] + }; +} diff --git a/src/components/Dashboard/ApiDocs/apis/MrzScanner.js b/src/components/Dashboard/ApiDocs/apis/MrzScanner.js new file mode 100644 index 00000000000..54a650ad1d5 --- /dev/null +++ b/src/components/Dashboard/ApiDocs/apis/MrzScanner.js @@ -0,0 +1,317 @@ +// src/components/Dashboard/ApiDocs/apis/MrzScanner.jsx +import React from "react"; +import CodeBlock from "../CodeBlock"; + +const MrzScannerI18n = { + "zh-hant": { + mrzScannerTitle: "MRZScanner", + mrzScannerPath: "POST /mrzscanner-public-predict", + mrzScannerOverview: ` + 這個 API 用於掃描並解析 MRZ 區域,可選擇先對齊影像 (do_doc_align),再決定是否後處理 (do_postprocess)、中心裁切等。 + `, + parameters: "參數說明", + codeExamples: "程式碼範例", + requiredLabel: "必填", + defaultLabel: "預設值", + curlLabel: "cURL", + pythonLabel: "Python", + nodeLabel: "Node.js", + jsLabel: "JavaScript", + javaLabel: "Java", + rubyLabel: "Ruby", + mrzScannerParams: [ + { + name: "file", + type: "File", + required: true, + default: "-", + desc: "要上傳的影像檔 (jpg, png 等)。" + }, + { + name: "do_doc_align", + type: "bool", + required: false, + default: "false", + desc: "是否先使用 DocAligner 對齊影像。" + }, + { + name: "do_postprocess", + type: "bool", + required: false, + default: "false", + desc: "是否在辨識後進行後處理 (去雜訊)。" + }, + { + name: "do_center_crop", + type: "bool", + required: false, + default: "true", + desc: "是否在掃描前做中心裁切。" + } + ] + }, + en: { + mrzScannerTitle: "MRZScanner", + mrzScannerPath: "POST /mrzscanner-public-predict", + mrzScannerOverview: ` + This API scans and parses the MRZ zone, optionally aligns the document first (do_doc_align), + then you can decide whether to post-process (do_postprocess) or center-crop. + `, + parameters: "Parameters", + codeExamples: "Code Examples", + requiredLabel: "Required", + defaultLabel: "Default", + curlLabel: "cURL", + pythonLabel: "Python", + nodeLabel: "Node.js", + jsLabel: "JavaScript", + javaLabel: "Java", + rubyLabel: "Ruby", + mrzScannerParams: [ + { + name: "file", + type: "File", + required: true, + default: "-", + desc: "Uploaded image file (jpg, png, etc.)." + }, + { + name: "do_doc_align", + type: "bool", + required: false, + default: "false", + desc: "Whether to align the document first." + }, + { + name: "do_postprocess", + type: "bool", + required: false, + default: "false", + desc: "Whether to apply post-processing (noise removal)." + }, + { + name: "do_center_crop", + type: "bool", + required: false, + default: "true", + desc: "Whether to apply center cropping before scanning." + } + ] + }, + ja: { + mrzScannerTitle: "MRZScanner", + mrzScannerPath: "POST /mrzscanner-public-predict", + mrzScannerOverview: ` + このAPIはMRZ領域をスキャンして解析します。事前にドキュメントアライメント(do_doc_align)を + 行うことも可能で、後処理(do_postprocess)や中心トリミング(do_center_crop)なども選択できます。 + `, + parameters: "パラメータ", + codeExamples: "コード例", + requiredLabel: "必須", + defaultLabel: "デフォルト", + curlLabel: "cURL", + pythonLabel: "Python", + nodeLabel: "Node.js", + jsLabel: "JavaScript", + javaLabel: "Java", + rubyLabel: "Ruby", + mrzScannerParams: [ + { + name: "file", + type: "File", + required: true, + default: "-", + desc: "アップロードする画像ファイル(jpg, pngなど)。" + }, + { + name: "do_doc_align", + type: "bool", + required: false, + default: "false", + desc: "先にDocAlignerで画像を整列させるかどうか。" + }, + { + name: "do_postprocess", + type: "bool", + required: false, + default: "false", + desc: "スキャン後にノイズ除去などの後処理を行うかどうか。" + }, + { + name: "do_center_crop", + type: "bool", + required: false, + default: "true", + desc: "スキャン前に中心部分のトリミングを行うかどうか。" + } + ] + } +}; + +export default function MrzScanner(locale = "en") { + const text = MrzScannerI18n[locale] || MrzScannerI18n.en; + return { + key: "mrzscanner", + text, + title: text.mrzScannerTitle, + route: text.mrzScannerPath, + overview: text.mrzScannerOverview, + params: text.mrzScannerParams, + codeExamples: [ + { + label: text.curlLabel, + key: "curl", + children: ( + " \\ + -F "file=@/path/to/your/document.jpg" \\ + -F "do_doc_align=true" \\ + -F "do_postprocess=false" \\ + -F "do_center_crop=true" +`} + /> + ) + }, + { + label: text.pythonLabel, + key: "python", + children: ( + "} +files = {"file": open("/path/to/your/document.jpg", "rb")} +data = { + "do_doc_align": "true", + "do_postprocess": "false", + "do_center_crop": "true" +} + +res = requests.post(url, headers=headers, files=files, data=data) +print(res.json()) +`} + /> + ) + }, + { + label: text.nodeLabel, + key: "node", + children: ( + ' + } +}).then(res => { + console.log(res.data); +}).catch(err => { + console.error(err); +}); +`} + /> + ) + }, + { + label: text.jsLabel, + key: "js", + children: ( + " + }, + body: form +}) + .then(r => r.json()) + .then(data => console.log(data)) + .catch(err => console.error(err)); +`} + /> + ) + }, + { + label: text.javaLabel, + key: "java", + children: ( + ") + .build(); + +try (Response response = client.newCall(request).execute()) { + System.out.println(response.body().string()); +} +`} + /> + ) + }, + { + label: text.rubyLabel, + key: "ruby", + children: ( + + ) + } + ] + }; +} diff --git a/src/components/Dashboard/ApiDocs/index.js b/src/components/Dashboard/ApiDocs/index.js index 162a77dba19..8aeb4f74c44 100644 --- a/src/components/Dashboard/ApiDocs/index.js +++ b/src/components/Dashboard/ApiDocs/index.js @@ -1,14 +1,93 @@ // src/components/Dashboard/ApiDocs/index.js -import React from "react"; +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; +import { Spin } from "antd"; +import React, { useEffect, useState } from "react"; +import { useAuth } from "../../../context/AuthContext"; import ApiUsageExamples from "./ApiUsageExamples"; import styles from "./index.module.css"; +const locales = { + "zh-hant": { + title: "API 使用說明", + description: "這裡是各種 API 的技術文件與使用範例介紹頁。", + pleaseVerifyEmailTitle: "請先驗證電子郵件", + pleaseVerifyEmailDesc: + "您尚未完成電子郵件驗證,無法使用 API 文件功能。請前往 我的資訊 頁面完成驗證。", + loadingProfile: "載入個人資料...", + }, + en: { + title: "API Documents", + description: + "This page provides technical documentation and usage examples for various APIs.", + pleaseVerifyEmailTitle: "Please verify your email", + pleaseVerifyEmailDesc: + "You have not verified your email, and cannot use the API Documentation feature. Please go to My Info page to complete verification.", + loadingProfile: "Loading profile...", + }, + ja: { + title: "APIドキュメント", + description: "ここでは、各種APIの技術文書と使用例を紹介しています。", + pleaseVerifyEmailTitle: "メール認証をしてください", + pleaseVerifyEmailDesc: + "メール認証が完了していないため、APIドキュメント機能が利用できません。マイ情報ページに移動して認証を完了してください。", + loadingProfile: "プロファイルを読み込み中...", + }, +}; + export default function DashboardApiDocs() { - return ( -
-

API Documents

-

這裡是各種 API 的技術文件與使用範例介紹頁。

+ const { + i18n: { currentLocale }, + } = useDocusaurusContext(); + const text = locales[currentLocale] || locales.en; + + const { token: userToken } = useAuth(); + const [userProfile, setUserProfile] = useState(null); + const [loadingProfile, setLoadingProfile] = useState(false); + + useEffect(() => { + if (userToken) { + const fetchUserProfile = async () => { + setLoadingProfile(true); + try { + const res = await fetch("https://api.docsaid.org/auth/me", { + headers: { Authorization: `Bearer ${userToken}` }, + }); + if (!res.ok) { + throw new Error("Failed to fetch profile"); + } + const data = await res.json(); + setUserProfile(data); + } catch (err) { + console.error(err); + } finally { + setLoadingProfile(false); + } + }; + fetchUserProfile(); + } + }, [userToken]); + if (loadingProfile && !userProfile) { + return ( +
+ +
+ ); + } + + if (userProfile && !userProfile.is_email_verified) { + return ( +
+

{text.pleaseVerifyEmailTitle}

+

{text.pleaseVerifyEmailDesc}

+
+ ); + } + + return ( +
+

{text.title}

+

{text.description}

); diff --git a/src/components/Dashboard/ApiDocs/index.module.css b/src/components/Dashboard/ApiDocs/index.module.css index b88b23c54de..862668b5537 100644 --- a/src/components/Dashboard/ApiDocs/index.module.css +++ b/src/components/Dashboard/ApiDocs/index.module.css @@ -1,47 +1,52 @@ /* src/components/Dashboard/ApiDocs/index.module.css */ -/* 主容器 (類似 apiKeyContainer) */ -.apiDocsContainer { +/* 容器 (與 APIKey 保持一致) */ +.apiKeyContainer { max-width: 900px; margin: 0 auto; - padding: 24px; + padding: 32px 24px; background-color: #fafafa; border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08); } -/* 頁面標題區域 (若需要) */ +/* 頁面標題 */ .header { - margin-bottom: 24px; - text-align: left; + margin-bottom: 32px; + text-align: center; } .header h2 { - font-size: 2rem; - margin: 0 0 8px 0; + font-size: 2.5rem; + margin: 0 0 12px; color: #333; + font-weight: 600; + white-space: nowrap; /* 不換行 */ + overflow: hidden; /* 超出隱藏 */ + text-overflow: ellipsis; /* 超出部分以省略號顯示 */ } .header p { - font-size: 1rem; - color: #555; + font-size: 1.125rem; + color: #666; margin: 0; + line-height: 1.5; } -/* 折疊面板外框 (Collapse) */ +/* 折疊面板外框 */ .collapseRoot { background-color: #fff; border: 1px solid #e8e8e8; border-radius: 8px; - margin-bottom: 24px; + margin-bottom: 32px; } -/* 卡片 (API 詳細內容) */ +/* API 詳細卡片 */ .apiCard { background-color: #fff; border-radius: 8px; - padding: 16px 24px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + padding: 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); transition: transform 0.2s ease, box-shadow 0.2s ease; - margin: 8px 0; + margin: 16px 0; } .apiCard:hover { transform: translateY(-2px); @@ -50,36 +55,47 @@ /* 參數列表 */ .paramListItem { - padding: 8px 0; - border: none !important; + padding: 12px 0; + border-bottom: 1px solid #eee; +} +.paramListItem:last-child { + border-bottom: none; } .paramTitle { + font-size: 1rem; + color: #333; margin-bottom: 0; } .paramTitle code { font-size: 0.95rem; margin-right: 4px; + background: #e8e8e8; + padding: 2px 4px; + border-radius: 4px; } .paramDesc { font-size: 0.9rem; color: #444; margin-left: 4px; + line-height: 1.5; } /* 區隔線 */ .divider { - margin-top: 24px; - margin-bottom: 16px; + margin: 32px 0 20px; + border: none; + border-top: 1px solid #e8e8e8; } -/* 程式碼區塊 (可進一步加入 syntax highlight) */ +/* 程式碼區塊 */ .codeBlock { background: #f5f5f5; border-radius: 4px; - padding: 12px; + padding: 16px; white-space: pre-wrap; font-family: Consolas, Menlo, monospace; - margin-top: 8px; + margin-top: 12px; + overflow-x: auto; } /* Collapse Panel header 文字 */ @@ -87,3 +103,73 @@ font-weight: 600; color: #333; } + +/* CodeBlock 相關樣式 */ +.codeBlockContainer { + position: relative; +} +.copyButton { + position: absolute; + top: 8px; + right: 8px; + z-index: 10; +} + +/* 手機裝置調整 */ +@media (max-width: 576px) { + .apiKeyContainer { + padding: 16px; + } + .header h2 { + font-size: 1.75rem; + } + .header p { + font-size: 1rem; + } + .apiCard { + padding: 16px; + margin: 12px 0; + } + .divider { + margin: 24px 0 16px; + } + .paramListItem { + padding: 10px 0; + } +} + +/* 平板裝置調整 */ +@media (min-width: 768px) and (max-width: 991px) { + .apiKeyContainer { + padding: 24px; + } + .header h2 { + font-size: 2rem; + } + .header p { + font-size: 1.05rem; + } +} + +/* 新增:Panel header wrapper,讓標題和路由可以換行 */ +.panelHeaderWrapper { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + white-space: normal; +} + +/* 如果需要,可調整 .panelHeader 原有樣式 */ +.panelHeader { + font-weight: 600; + color: #333; +} + +/* 新增:Panel header 的 wrapper */ +.panelHeaderWrapper { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; +} diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index a712259ca31..ea9a0382330 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -42,14 +42,14 @@ const localeText = { menu: { myinfo: "我的資訊", apikey: "我的 API Key", - apidocs: "API 文件", // 新增 + apidocs: "API 使用說明", }, }, breadcrumb: { dashboard: "我的後台", myinfo: "我的資訊", apikey: "我的 API Key", - apidocs: "API 文件", // 新增 + apidocs: "API 使用說明", undefined: "未定義", }, userMenu: { @@ -67,14 +67,14 @@ const localeText = { menu: { myinfo: "My Information", apikey: "My API Key", - apidocs: "API Docs", + apidocs: "API Documentation", }, }, breadcrumb: { dashboard: "Dashboard", myinfo: "My Information", apikey: "My API Key", - apidocs: "API Docs", + apidocs: "API Documentation", undefined: "Undefined", }, userMenu: { @@ -92,14 +92,14 @@ const localeText = { menu: { myinfo: "マイ情報", apikey: "マイAPIキー", - apidocs: "APIドキュメント", + apidocs: "API利用ガイド", }, }, breadcrumb: { dashboard: "ダッシュボード", myinfo: "マイ情報", apikey: "マイAPIキー", - apidocs: "APIドキュメント", + apidocs: "API利用ガイド", undefined: "未定義", }, userMenu: { @@ -109,6 +109,7 @@ const localeText = { }, }; + export default function DashboardPage() { const { i18n: { currentLocale },