diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dbcc2f3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,39 @@ +# 依赖 +node_modules +npm-debug.log +yarn-error.log +yarn-debug.log +package-lock.json + +# 构建产物 +dist +build + +# 环境变量 +.env.local +.env.*.local + +# IDE +.vscode +.idea +*.swp +*.swo +.DS_Store + +# Git +.git +.gitignore +.gitattributes + +# 文档 +README.md +*.md + +# 测试 +coverage +.nyc_output + +# 其他 +*.log +temp +tmp diff --git a/.env.development b/.env.development index 083c815..4db40c6 100644 --- a/.env.development +++ b/.env.development @@ -1 +1,71 @@ -NODE_ENV=development \ No newline at end of file +NODE_ENV=development + +# 百度翻译API配置 +VITE_BAIDU_APP_ID=your_baidu_app_id_here +VITE_BAIDU_SECRET_KEY=your_baidu_secret_key_here + +# DeepSeek/OpenAI API配置 +VITE_OPENAI_BASE_URL=https://api.deepseek.com +VITE_OPENAI_API_KEY=your_openai_api_key_here + +# 动漫漫画配置 +BCOMIC_API_BASE_URL=your_bcomic_api_base_url_here + +# 黄金配置 +VITE_GOLD_API_BASE_URL=your_gold_api_base_url_here + +# 开发环境使用本地 Hono 代理(局域网可访问) +# Vite 代理配置,自动转发到本地 Hono 服务器 +VITE_API_BASE_URL=/api +VITE_PROXY_TARGET=http://localhost:3001 + +# Google OAuth 配置 +VITE_GOOGLE_CLIENT_ID=your_google_client_id_here + +# GitHub OAuth 配置 +VITE_GITHUB_CLIENT_ID=your_github_client_id_here + +# 论文API配置 +VITE_PAPERS_API_URL=your_papers_api_url_here + +# EmailJS 配置 +VITE_EMAILJS_PUBLIC_KEY=YOUR_PUBLIC_KEY_HERE +VITE_EMAILJS_SERVICE_ID=YOUR_SERVICE_ID_HERE +VITE_EMAILJS_TEMPLATE_ID=YOUR_TEMPLATE_ID_HERE +VITE_EMAILJS_FROM_NAME=ChattyPlay +VITE_EMAILJS_FROM_EMAIL=891523233@qq.com + +# 视频去水印解析下载配置 +VIDEO_PARSE_API_BASE_URL=your_video_parse_api_base_url_here + +# 视频解析接口列表 +VITE_VIDEO_PARSE_URL1=your_video_parse_url1_here +VITE_VIDEO_PARSE_URL2=your_video_parse_url2_here +VITE_VIDEO_PARSE_URL3=your_video_parse_url3_here +VITE_VIDEO_PARSE_URL4=your_video_parse_url4_here +VITE_VIDEO_PARSE_URL5=your_video_parse_url5_here + +# 埋点SDK配置 +VITE_ANALYTICS_REPORT_URL=your_analytics_report_url_here + +# agent配置 +VITE_AGENT_API_URL=your_agent_api_url_here + +# Redis配置 +UPSTASH_REDIS_REST_URL=https://your-redis-url.upstash.io +UPSTASH_REDIS_REST_TOKEN=your_redis_token_here + +# hcaptcha 配置 +VITE_HCAPTCHA_SITE_KEY=your_hcaptcha_site_key_here + +# Cloudflare Turnstile 配置 +VITE_TURNSTILE_SITE_KEY=your_turnstile_site_key_here +TURNSTILE_SECRET_KEY=your_turnstile_secret_key_here + +# 支付 +VITE_PAY_URL=your_pay_url_here +VITE_PAY_BEARER_KEY=your_pay_bearer_key_here + +# 闲鱼常驻后端 +VITE_GOOFISH_API_URL=your_goofish_api_url_here +VITE_GOOFISH_WS_URL=your_goofish_ws_url_here diff --git a/.env.production b/.env.production index 995fca4..28dd650 100644 --- a/.env.production +++ b/.env.production @@ -1 +1,70 @@ -NODE_ENV=production \ No newline at end of file +NODE_ENV=production + +# 百度翻译API配置 +VITE_BAIDU_APP_ID=your_baidu_app_id_here +VITE_BAIDU_SECRET_KEY=your_baidu_secret_key_here + +# DeepSeek/OpenAI API配置 +VITE_OPENAI_BASE_URL=https://api.deepseek.com +VITE_OPENAI_API_KEY=your_openai_api_key_here + +# 动漫漫画配置 +BCOMIC_API_BASE_URL=your_bcomic_api_base_url_here + +# 黄金配置 +VITE_GOLD_API_BASE_URL=your_gold_api_base_url_here + +# 生产环境使用宝塔服务器 +# 部署到 http://123.60.91.107/ +VITE_API_BASE_URL=http://123.60.91.107:3001/api + +# Google OAuth 配置 +VITE_GOOGLE_CLIENT_ID=your_google_client_id_here + +# GitHub OAuth 配置 +VITE_GITHUB_CLIENT_ID=your_github_client_id_here + +# 论文API配置 +VITE_PAPERS_API_URL=your_papers_api_url_here + +# EmailJS 配置 +VITE_EMAILJS_PUBLIC_KEY=YOUR_PUBLIC_KEY_HERE +VITE_EMAILJS_SERVICE_ID=YOUR_SERVICE_ID_HERE +VITE_EMAILJS_TEMPLATE_ID=YOUR_TEMPLATE_ID_HERE +VITE_EMAILJS_FROM_NAME=ChattyPlay +VITE_EMAILJS_FROM_EMAIL=891523233@qq.com + +# 视频去水印解析下载配置 +VIDEO_PARSE_API_BASE_URL=your_video_parse_api_base_url_here + +# 视频解析接口列表 +VITE_VIDEO_PARSE_URL1=your_video_parse_url1_here +VITE_VIDEO_PARSE_URL2=your_video_parse_url2_here +VITE_VIDEO_PARSE_URL3=your_video_parse_url3_here +VITE_VIDEO_PARSE_URL4=your_video_parse_url4_here +VITE_VIDEO_PARSE_URL5=your_video_parse_url5_here + +# 埋点SDK配置 +VITE_ANALYTICS_REPORT_URL=your_analytics_report_url_here + +# agent配置 +VITE_AGENT_API_URL=your_agent_api_url_here + +# Redis配置 +UPSTASH_REDIS_REST_URL=https://your-redis-url.upstash.io +UPSTASH_REDIS_REST_TOKEN=your_redis_token_here + +# hcaptcha 配置 +VITE_HCAPTCHA_SITE_KEY=your_hcaptcha_site_key_here + +# Cloudflare Turnstile 配置 +VITE_TURNSTILE_SITE_KEY=your_turnstile_site_key_here +TURNSTILE_SECRET_KEY=your_turnstile_secret_key_here + +# 支付 +VITE_PAY_URL=your_pay_url_here +VITE_PAY_BEARER_KEY=your_pay_bearer_key_here + +# 闲鱼常驻后端 +VITE_GOOFISH_API_URL=your_goofish_api_url_here +VITE_GOOFISH_WS_URL=your_goofish_ws_url_here diff --git a/.gitignore b/.gitignore index 403adbc..24ad949 100644 --- a/.gitignore +++ b/.gitignore @@ -2,10 +2,19 @@ node_modules /dist +# 闲鱼日志文件 +/logs +# 闲鱼数据库文件 +/data -# local env files + +# local env files - ignore all .env files except .env.example +.env .env.local .env.*.local +.env.development +.env.production +.env.test # Log files npm-debug.log* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7852bd4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# 多阶段构建 - 构建阶段 +FROM node:20-alpine AS builder + +# 设置工作目录 +WORKDIR /app + +# 安装 yarn +RUN corepack enable && corepack prepare yarn@stable --activate + +# 复制 package.json 和 yarn.lock +COPY package.json yarn.lock ./ + +# 安装依赖 +RUN yarn install --frozen-lockfile + +# 复制源代码 +COPY . . + +# 构建生产环境代码 +RUN yarn build + +# 生产阶段 - 使用 nginx 托管静态文件 +FROM nginx:alpine + +# 复制 nginx 配置 +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# 从构建阶段复制构建产物 +COPY --from=builder /app/dist /usr/share/nginx/html + +# 暴露端口 +EXPOSE 80 + +# 启动 nginx +CMD ["nginx", "-g", "daemon off;"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 743572c..04407d5 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,461 @@ -# vip-video-parsing +
ChattyPlay-Agent
+
+
+
+
+
+
+
ChattyPlay-Agent
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {t('versionUpdate.message')} +
++ {t('versionUpdate.currentVersion')}{versionInfo.stored} +
++ {t('versionUpdate.latestVersion')}{versionInfo.current} +
++ {t('versionUpdate.description')} +
+ +图表渲染失败:${escapeHtml(message)}`
+ node.dataset.rendered = '1'
+ node.classList.remove('md-mermaid-loading')
+ }
+ }
+ },
+ [theme]
+ )
+
+ // 源码稳定后再渲染图表(首次进入还需等待 Mermaid chunk 加载)
+ useEffect(() => {
+ const el = containerRef.current
+ if (!el) return
+ const token = ++taskRef.current
+ const timer = window.setTimeout(() => {
+ void renderPendingDiagrams(token)
+ }, 180)
+ return () => window.clearTimeout(timer)
+ }, [html, theme, renderPendingDiagrams])
+
+ // 目录点击:滚动到对应标题
+ useEffect(() => {
+ const el = containerRef.current
+ if (!el || !scrollToHeading) return
+ const target = el.querySelector正在解析视频信息...
+{{CONTENT}} in the prompt template, which will be replaced with the input content from the left.',
+ editNodeTitle: '✏️ Edit Node',
+ deleteNodeBtn: '🗑️ Delete Node',
+ helpBtnTitle: 'Help & Information',
+ infoModalTitle: 'Instructions, Terms & Privacy Policy',
+ infoModalContentHtml: `
+ ⚙️ API Settings button in the top right to enter the API URL and Key provided by your AI service provider. The configuration will be saved locally in your browser.🔍 Query to automatically fetch a list of supported models from the API URL.🚀 AI Generate.📝 Show Markdown button on the left allows you to view and edit the AI-generated Markdown source.SVG or PNG image, or enter fullscreen mode.By using AiMarkmap, you agree to the following terms:
+We take your privacy very seriously. Please read the following information carefully about how we handle your data:
+This product primarily handles two types of data:
+localStorage technology to simplify your subsequent use and is not uploaded to AiMarkmap's servers.This product does not use any third-party analytics tools (like Google Analytics) to track your personal behavior.
+All of your data processing is done on the client-side (in your browser). The process is as follows:
+As a client-side tool, this product calls third-party AI services based on your configuration. The data you send is subject to the privacy policy and data usage terms of the AI service provider you use. We strongly recommend that you review the official privacy policies of the respective service providers before use.
+{{CONTENT}},它将被替换为左侧的输入内容。',
+ editNodeTitle: '✏️ 编辑节点',
+ deleteNodeBtn: '🗑️ 删除节点',
+ helpBtnTitle: '帮助与信息',
+ infoModalTitle: '使用说明、服务条款和隐私政策',
+ infoModalContentHtml: `
+ ⚙️ API设置 按钮,填入您的 AI 服务商提供的 API 地址 (URL) 和密钥 (Key)。配置将自动保存在您的浏览器本地。🔍 查询 可自动获取该 API 地址下支持的模型列表。🚀 AI生成。📝 显示Markdown 按钮可以让你查看和编辑 AI 生成的 Markdown 源码。SVG 或 PNG 图片,或进入全屏模式。当您使用 Markmap 时,即表示您同意以下条款:
+我们高度重视您的隐私。请仔细阅读以下关于我们如何处理您的数据的信息:
+本产品主要处理两类数据:
+localStorage 技术存储在您自己的电脑上,用于简化您的后续使用,不会上传到 AiMarkmap 的服务器。本产品不使用任何第三方分析工具(如 Google Analytics)来追踪您的个人行为。
+您的所有数据处理均在浏览器端(客户端)完成。具体流程如下:
+本产品作为一个客户端工具,会根据您的配置调用第三方 AI 服务。您发送的数据将受您所使用的 AI 服务提供商的隐私政策和数据使用条款约束。我们强烈建议您在使用前查阅相应服务商的官方隐私政策。
+{selectedProduct.desc || '无限制使用网站所有功能'}
+ {selectedProduct.link && ( ++ 🔗 文档链接: + + 查看详情文档 → + +
+ )} +([\s\S]*?)<\/code><\/pre>/g,
+ (_match: string, lang: string, code: string) => {
+ const escapedCode = code.replace(/"/g, '"')
+ return `
+
+ ${lang}
+
+
+ ${code}
+ `
+ }
+ )
+ }, [])
+
+ // 格式化时间
+ const formatTime = useCallback((timestamp: number) => {
+ const date = new Date(timestamp)
+ return date
+ .toLocaleString('zh-CN', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false
+ })
+ .replace(/\//g, '-')
+ .replace(/,/g, '')
+ }, [])
+
+ // 发送消息 - 流式处理
+ const sendMessage = async () => {
+ if (!inputMessage.trim() || isStreaming) {
+ return
+ }
+
+ try {
+ const token = await new Promise((resolve, reject) => {
+ const timeoutId = setTimeout(() => {
+ if (captchaPromiseRef.current) {
+ reject(new Error('验证超时,请重试'))
+ captchaPromiseRef.current = null
+ }
+ }, 120000)
+
+ captchaPromiseRef.current = { resolve, reject, timeoutId }
+
+ if (hcaptchaRef.current) {
+ hcaptchaRef.current.execute()
+ } else {
+ reject(new Error('HCaptcha 组件未初始化'))
+ }
+ })
+
+ console.log('验证通过,token:', token)
+ const userMessage = inputMessage.trim()
+ const newMessages = [
+ ...messages,
+ {
+ role: 'user' as const,
+ content: userMessage,
+ timestamp: Date.now()
+ }
+ ]
+
+ setMessages(newMessages)
+ setInputMessage('')
+ scrollToBottom()
+ focusInput()
+
+ setIsStreaming(true)
+ setStreamingContent('')
+
+ // 创建中止控制器
+ abortControllerRef.current = new AbortController()
+
+ try {
+ // 准备对话历史
+ const conversationHistory = newMessages
+ .filter((msg) => msg.role === 'user' || msg.role === 'assistant')
+ .map((msg) => ({
+ role: msg.role,
+ content: msg.content
+ }))
+
+ // 调用流式 API
+ const stream = await openai.chat.completions.create(
+ {
+ model: 'deepseek-v4-flash-260425',
+ messages: [
+ {
+ role: 'system',
+ content: `You are a ChattyPlay AI assistant, focused on helping users learn, answer questions, and provide study advice. Please answer in a friendly, professional tone, and try to use Markdown format to organize your content, including:
+- Use headings (#, ##, ###) to organize content structure
+- Use lists (-, 1.) to enumerate key points
+- Use code blocks (\`\`\`) to display code examples
+- Use bold (**) to emphasize important concepts
+- Use tables to compare information
+
+Please ensure your answers are accurate, detailed, and easy to understand.`
+ },
+ ...conversationHistory
+ ],
+ stream: true,
+ max_tokens: 2000
+ },
+ {
+ signal: abortControllerRef.current.signal
+ }
+ )
+
+ // 处理流式响应
+ let accumulatedContent = ''
+ for await (const chunk of stream) {
+ const content = chunk.choices[0]?.delta?.content
+ if (content) {
+ accumulatedContent += content
+ setStreamingContent(accumulatedContent)
+ }
+ }
+
+ // 流式完成,将内容添加到消息列表
+ if (accumulatedContent) {
+ setMessages((prev) => [
+ ...prev,
+ {
+ role: 'assistant',
+ content: accumulatedContent,
+ timestamp: Date.now()
+ }
+ ])
+ message.success(t('gpt.replyComplete'))
+ }
+ } catch (error: any) {
+ if (error.name === 'AbortError') {
+ console.log('请求被用户中止')
+ if (streamingContent) {
+ setMessages((prev) => [
+ ...prev,
+ {
+ role: 'assistant',
+ content: streamingContent,
+ timestamp: Date.now()
+ }
+ ])
+ message.info(t('gpt.replyInterrupted'))
+ }
+ } else {
+ console.error('API调用错误:', error)
+ // 备用回复
+ const fallbackResponse = `${t('gpt.fallbackResponse')}
+
+## Learning Suggestions
+1. **Understand Core Concepts**: Master the basics first
+2. **Practice**: Deepen understanding through practice
+3. **Seek Help**: Ask teachers or classmates for assistance
+
+## Recommended Resources
+- Relevant textbooks and reference materials
+- Online learning platforms
+- Educational video resources
+
+If you need more detailed answers, please try again later or contact technical support.`
+ setMessages((prev) => [
+ ...prev,
+ {
+ role: 'assistant',
+ content: fallbackResponse,
+ timestamp: Date.now()
+ }
+ ])
+ message.error(t('gpt.networkError'))
+ }
+ } finally {
+ setIsStreaming(false)
+ setStreamingContent('')
+ abortControllerRef.current = null
+ // 清除url参数
+ window.history.replaceState({}, document.title, window.location.pathname)
+ scrollToBottom()
+ }
+
+ } catch (error) {}
+
+ }
+
+ useEffect(() => {
+ const urlParams = new URLSearchParams(window.location.search)
+ const paperTitle = urlParams.get('paper')
+ const pdfUrl = urlParams.get('pdf')
+ // 判断是否url上有paper和pdf参数
+ if (paperTitle && pdfUrl) {
+ let content = `
+ 论文解读分析,论文链接是:${pdfUrl},论文标题是:${paperTitle}。
+
+ 你是一名AI领域的研究生,目标是深入理解论文的方法部分,包括方法动机、设计逻辑、流程细节、优势与不足,以便学习和在研究中借鉴。你的角色是高效、深入的论文分析师。
+
+ 任务:
+ 请在阅读论文(用户提供的论文链接和论文标题)后,围绕以下要点进行总结和分析:
+ 0. 翻译摘要原文
+ 1. 方法动机
+ a) 作者为什么提出这个方法?阐述其背后的驱动力。
+ b) 现有方法的痛点/不足是什么?具体指出局限性。
+ c) 论文的研究假设或直觉是什么?用简洁语言概括。
+ 2. 方法设计
+ a) 给出清晰的方法流程总结(pipeline),逐步解释输入→处理→输出。必须讲清楚每一步的具体操作和技术细节。这一步必须非常细致,这是用户的主要阅读目标。
+ b) 如果涉及模型结构,请描述每个模块的功能与作用,以及它们如何协同工作。
+ c) 如果有公式/算法,请用通俗语言解释它们的意义和在方法中的角色。
+ 3. 与其他方法对比
+ a) 本方法和现有主流方法相比,有什么本质不同?
+ b) 创新点在哪里?明确指出贡献度。
+ c) 在什么场景下更适用?分析其适用范围。
+ d) 用表格总结 方法对比(优点/缺点/改进点),确保对比项清晰。
+ 4. 实验表现与优势
+ a) 作者如何验证该方法的有效性?描述实验设计和设置。
+ b) 实验结果在哪些指标上超越了对比方法?列出几个最具代表性的关键数据和结论。
+ c) 哪些场景/数据集下优势最明显?提供具体证据。
+ d) 是否有局限性(比如泛化能力、计算开销、对特定数据的依赖)?指出论文中承认或隐含的不足。
+ 5. 学习与应用
+ a) 论文是否开源?如果我想实现/复现这个方法,关键步骤是什么?
+ b) 需要注意哪些超参数、数据预处理、训练细节?提供实现层面的建议。
+ c) 该方法能否迁移到其他任务?如果能,如何迁移?
+ 6. 总结
+ a) 用一句话概括这个方法的核心思想(不超过50字)。
+ b) 给出一个“速记版pipeline”(使用3-5个关键步骤),方便记忆。这个pipeline不要使用论文使用的专业词汇,而是应当具有自明性,让读者只看pipeline即可大体理解论文内容。不要用比喻,直白的讲出内容。
+
+ 行为和规则:
+ - 语言风格:专业、严谨、逻辑性强,完全采用中文进行回复。
+ - 回复结构:严格按照上述六个大点和其子点进行分析和总结,使用清晰的分段和编号。
+ - 数据来源:所有分析必须基于用户提供的论文内容信息。如果用户没有提供足够信息,可以基于常识进行合理推测,但必须明确指出是推测。
+ - 聚焦核心:重点解析方法(Methodology)部分,避免过度讨论引言和结论。
+ - 输出要求:用户可能不再阅读论文具体内容,而是只阅读你提供的信息,因此请确保你的分析详尽且准确。
+ `
+ setIsStreaming(false)
+ autoSendTriggerRef.current = content
+ setInputMessage(content)
+ }
+ }, [])
+
+ // 停止生成
+ const stopGeneration = () => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort()
+ setIsStreaming(false)
+ }
+ }
+
+ // 复制消息
+ const copyMessage = async (content: string) => {
+ try {
+ await navigator.clipboard.writeText(content)
+ message.success(t('gpt.copySuccess'))
+ } catch (err) {
+ const textArea = document.createElement('textarea')
+ textArea.value = content
+ document.body.appendChild(textArea)
+ textArea.select()
+ document.execCommand('copy')
+ document.body.removeChild(textArea)
+ message.success(t('gpt.copySuccess'))
+ }
+ }
+
+ // 语音播报
+ const speakMessage = (content: string) => {
+ if (!('speechSynthesis' in window)) {
+ message.warning(t('gpt.browserNotSupportSpeech'))
+ return
+ }
+
+ if (speechSynthesisRef.current) {
+ speechSynthesisRef.current.cancel()
+ }
+
+ // 移除Markdown标记,只播报纯文本
+ const plainText = content
+ .replace(/#{1,6}\s?/g, '') // 移除标题标记
+ .replace(/\*\*(.*?)\*\*/g, '$1') // 移除粗体
+ .replace(/\*(.*?)\*/g, '$1') // 移除斜体
+ .replace(/`(.*?)`/g, '$1') // 移除行内代码
+ .replace(/```[\s\S]*?```/g, '') // 移除代码块
+ .replace(/\[([^\[]+)\]\(([^\)]+)\)/g, '$1') // 移除链接标记
+
+ const utterance = new SpeechSynthesisUtterance(plainText)
+ utterance.lang = 'zh-CN'
+ utterance.rate = 1
+ utterance.pitch = 1
+ utterance.volume = 1
+ utterance.onstart = () => {
+ message.info(t('gpt.speakStart'))
+ }
+ utterance.onend = () => {
+ message.success(t('gpt.speakComplete'))
+ }
+ utterance.onerror = () => {
+ message.error(t('gpt.speakError'))
+ }
+
+ speechSynthesisRef.current?.speak(utterance)
+ }
+
+ // 评分消息
+ const rateMessage = (index: number, rating: 'like' | 'dislike') => {
+ if (messages[index].role === 'assistant') {
+ const newMessages = [...messages]
+ newMessages[index].rating = rating
+ setMessages(newMessages)
+ message.success(rating === 'like' ? t('gpt.feedbackLike') : t('gpt.feedbackDislike'))
+ }
+ }
+
+ // 清空消息
+ const clearMessages = () => {
+ Modal.confirm({
+ title: t('gpt.clearConfirmTitle'),
+ content: t('gpt.clearConfirmContent'),
+ okText: t('common.confirm'),
+ cancelText: t('common.cancel'),
+ onOk: () => {
+ setMessages([
+ {
+ role: 'assistant',
+ content: t('gpt.welcomeMessage'),
+ timestamp: Date.now()
+ }
+ ])
+ localStorage.removeItem('chat_messages')
+ message.success(t('gpt.cleared'))
+ scrollToBottom()
+ }
+ })
+ }
+
+ // 导出聊天记录
+ const exportChat = () => {
+ const chatText = messages
+ .map((msg) => {
+ const role = msg.role === 'user' ? t('gpt.userRole') : t('gpt.aiRole')
+ const time = formatTime(msg.timestamp)
+ return `[${role} ${time}]\n${msg.content}\n`
+ })
+ .join('\n' + '='.repeat(50) + '\n\n')
+
+ const blob = new Blob([chatText], { type: 'text/plain;charset=utf-8' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = `${t('gpt.exportFilename')}_${new Date().toISOString().slice(0, 10)}.txt`
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+ message.success(t('gpt.exportSuccess'))
+ }
+
+ // 处理键盘事件
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ sendMessage()
+ }
+ }
+
+ const handleVerify = (token: string) => {
+ console.log('hcaptcha验证成功', token)
+ if (captchaPromiseRef.current) {
+ if ((captchaPromiseRef.current as any).timeoutId) {
+ clearTimeout((captchaPromiseRef.current as any).timeoutId)
+ }
+ captchaPromiseRef.current.resolve(token)
+ captchaPromiseRef.current = null
+ }
+ }
+
+ const handleError = (err: any) => {
+ console.error('hcaptcha验证失败', err)
+ if (captchaPromiseRef.current) {
+ if ((captchaPromiseRef.current as any).timeoutId) {
+ clearTimeout((captchaPromiseRef.current as any).timeoutId)
+ }
+ captchaPromiseRef.current.reject(new Error('验证失败,请重试'))
+ captchaPromiseRef.current = null
+ }
+ message.error('验证失败,请重试')
+ }
+
+ return (
+
+ {/* 主聊天区域 */}
+
+ {/* 头部 */}
+
+
+
+
+ {t('gpt.title')}
+ {t('gpt.subtitle')}
+
+
+
+ {t('gpt.modelName')}} />
+ {isStreaming && (
+ }
+ className="text-xs px-2"
+ >
+ {t('gpt.stopGenerate')}
+
+ )}
+
+
+
+
+ {/* 消息区域 */}
+ 0 ? '' : 'flex items-center justify-center'
+ }`}
+ >
+ {/* 欢迎消息 */}
+ {messages.length === 0 && (
+
+ }
+ />
+ {t('gpt.title')}
+
+ {t('gpt.welcomeMessage')}
+
+
+ )}
+
+ {/* 消息列表 */}
+ {messages.map((msg, index) => (
+
+ {/* 头像 */}
+ : }
+ />
+
+ {/* 消息内容 */}
+
+ {/* 操作按钮 */}
+
+
+ }
+ onClick={() => copyMessage(msg.content)}
+ />
+
+ {msg.role === 'assistant' && (
+ <>
+
+ }
+ onClick={() => speakMessage(msg.content)}
+ />
+
+
+ }
+ type={msg.rating === 'like' ? 'primary' : 'default'}
+ onClick={() => rateMessage(index, 'like')}
+ />
+
+ >
+ )}
+
+
+ {/* 消息内容 */}
+
+ {msg.role === 'assistant' ? (
+
+ ) : (
+ msg.content
+ )}
+
+
+ {/* 消息时间和评分 */}
+
+
+ {formatTime(msg.timestamp)}
+
+ {msg.role === 'assistant' && msg.rating && (
+
+ {msg.rating === 'like' ? (
+
+ ) : (
+
+ )}
+ {msg.rating === 'like' ? t('gpt.helpful') : t('gpt.needsImprovement')}
+
+ )}
+
+
+
+ ))}
+
+ {/* 流式输出显示 */}
+ {isStreaming && (
+
+ } />
+
+
+
+
+
+
+
+ {t('gpt.typing')}
+
+
+
+ |
+
+
+
+ )}
+
+
+ {/* 输入区域 */}
+
+ {/* 快捷操作栏 */}
+
+
+
+ } onClick={clearMessages} className="text-xs md:text-sm">
+ {t('gpt.clearBtn')}
+
+
+
+ } onClick={exportChat} className="text-xs md:text-sm">
+ {t('common.download')}
+
+
+
+
+
+ {t('gpt.enterToSend')}
+
+
+
+
+
+
+ }
+ className="h-10 md:h-12 px-3 md:px-6 bg-gradient-to-r from-blue-500 to-indigo-500 border-0 shadow-md hover:shadow-lg"
+ >
+ {isStreaming ? t('common.generating') : t('gpt.sendBtn')}
+
+
+
+ {
+ console.log('验证码过期')
+ message.warning('验证已过期,请重新验证')
+ }}
+ size="invisible"
+ />
+
+
+
+ )
+}
+
+export default GPT
diff --git a/src/pages/Gold.tsx b/src/pages/Gold.tsx
new file mode 100644
index 0000000..09fb244
--- /dev/null
+++ b/src/pages/Gold.tsx
@@ -0,0 +1,408 @@
+import React, { useState, useEffect, useRef } from 'react'
+import { useTranslation } from 'react-i18next'
+import { message, Modal, Input, Button, Spin } from 'antd'
+import axios from 'axios'
+import { marked } from 'marked'
+import * as echarts from 'echarts'
+import { formatPrice, formatChange, formatPercent, getPriceClass } from '@/utils/price'
+import '@/assets/css/gold.scss'
+import emailjs from '@emailjs/browser'
+// @ts-ignore
+import { emailConfig } from '../../email.config'
+
+interface PriceData {
+ international?: {
+ price: number;
+ change: number;
+ changePercent: number;
+ previousClose?: number;
+ silverPrice?: number;
+ source?: string;
+ };
+ domestic?: {
+ price: number;
+ change: number;
+ changePercent: number;
+ open?: number;
+ high?: number;
+ low?: number;
+ volume?: number;
+ source?: string;
+ };
+}
+
+interface KlineData {
+ date: string;
+ open: number;
+ high: number;
+ low: number;
+ close: number;
+ volume: number;
+}
+
+const Gold: React.FC = () => {
+ const { t } = useTranslation()
+ const [priceData, setPriceData] = useState({})
+ // @ts-ignore
+ const [klineData, setKlineData] = useState([])
+ // @ts-ignore
+ const [currentPeriod, setCurrentPeriod] = useState(30)
+ const [currentTime, setCurrentTime] = useState('')
+ const klineChartRef = useRef(null)
+ const chartInstanceRef = useRef(null)
+ const priceUpdateIntervalRef = useRef | null>(null)
+ const timeUpdateIntervalRef = useRef | null>(null)
+ const [subscribeModalVisible, setSubscribeModalVisible] = useState(false)
+ const [emailInput, setEmailInput] = useState('')
+ const [subscribing, setSubscribing] = useState(false)
+ const [loading, setLoading] = useState(true)
+ const isFirstLoadRef = useRef(true)
+
+ const API_BASE = import.meta.env.VITE_GOLD_API_BASE_URL || ''
+
+ const updateTime = (): void => {
+ const now = new Date()
+ setCurrentTime(now.toLocaleString('zh-CN', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ }))
+ }
+
+ // 刷新所有数据
+ const refreshAllData = (): void => {
+ fetchPriceData()
+ fetchKlineData(currentPeriod)
+ }
+
+ // 获取价格数据
+ const fetchPriceData = async (): Promise => {
+ try {
+ const response = await axios.get(`${API_BASE}/api/price/all`)
+ setPriceData({
+ international: response.data.international,
+ domestic: response.data.domestic,
+ })
+ } catch (error) {
+ console.error('获取价格数据失败:', error)
+ message.error(t('gold.fetchPriceFailed'))
+ } finally {
+ // 只在第一次加载时关闭 loading
+ if (isFirstLoadRef.current) {
+ setLoading(false)
+ isFirstLoadRef.current = false
+ }
+ }
+ }
+
+ // 获取K线数据
+ const fetchKlineData = async (days: number = 30): Promise => {
+ try {
+ const response = await axios.get(`${API_BASE}/api/chart/kline?days=${days}`)
+ setKlineData(response.data.rawData)
+ if (chartInstanceRef.current && response.data.chartOption) {
+ chartInstanceRef.current.setOption(response.data.chartOption)
+ }
+ } catch (error) {
+ console.error('获取K线数据失败:', error)
+ message.error(t('gold.fetchChartFailed'))
+ }
+ }
+
+ // 订阅金价
+ const handleSubscribe = async (): Promise => {
+ // 邮箱格式验证
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+ if (!emailInput || !emailRegex.test(emailInput)) {
+ message.error(t('gold.invalidEmail'))
+ return
+ }
+
+ // 检查 EmailJS 配置
+ if (emailConfig.PUBLIC_KEY === 'YOUR_PUBLIC_KEY_HERE' ||
+ emailConfig.SERVICE_ID === 'YOUR_SERVICE_ID_HERE' ||
+ emailConfig.TEMPLATE_ID === 'YOUR_TEMPLATE_ID_HERE') {
+ message.error(t('gold.emailNotConfigured'))
+ return
+ }
+
+ setSubscribing(true)
+ try {
+ // 初始化 EmailJS
+ emailjs.init(emailConfig.PUBLIC_KEY)
+
+ // 邮件参数
+ const templateParams = {
+ email: emailInput,
+ to_email: emailInput,
+ domestic_price: formatPrice(priceData.domestic?.price, 'CNY'),
+ international_price: formatPrice(priceData.international?.price, 'USD'),
+ current_time: currentTime,
+ from_name: emailConfig.fromName,
+ }
+
+ // 发送邮件
+ await emailjs.send(
+ emailConfig.SERVICE_ID,
+ emailConfig.TEMPLATE_ID,
+ templateParams
+ )
+
+ message.success(t('gold.subscribeSuccess'))
+ setSubscribeModalVisible(false)
+ setEmailInput('')
+ } catch (error) {
+ console.error('订阅失败:', error)
+ message.error(t('gold.subscribeFailed'))
+ } finally {
+ setSubscribing(false)
+ }
+ }
+
+ const initChart = (): void => {
+ // 初始化K线图
+ if (klineChartRef.current) {
+ chartInstanceRef.current = echarts.init(klineChartRef.current, 'dark')
+ }
+ window.addEventListener('resize', () => {
+ chartInstanceRef.current?.resize()
+ })
+ }
+
+ useEffect(() => {
+ // 配置marked选项
+ marked.setOptions({
+ breaks: true,
+ gfm: true,
+ })
+
+ // 初始化时间
+ updateTime()
+ timeUpdateIntervalRef.current = setInterval(updateTime, 1000)
+
+ // 初始化图表
+ initChart()
+
+ // 获取初始数据
+ fetchPriceData()
+ fetchKlineData(currentPeriod)
+
+ // 定时更新价格,每3秒更新一次
+ priceUpdateIntervalRef.current = setInterval(fetchPriceData, 3000)
+
+ // 清理函数
+ return () => {
+ if (priceUpdateIntervalRef.current) {
+ clearInterval(priceUpdateIntervalRef.current)
+ }
+ if (timeUpdateIntervalRef.current) {
+ clearInterval(timeUpdateIntervalRef.current)
+ }
+
+ // 销毁图表实例
+ if (chartInstanceRef.current) {
+ chartInstanceRef.current.dispose()
+ }
+
+ window.removeEventListener('resize', () => {
+ chartInstanceRef.current?.resize()
+ })
+ }
+ }, [currentPeriod])
+
+ return (
+
+ {/* Loading */}
+ {loading && (
+
+
+ {t('gold.loading')}
+
+ )}
+
+ {/* 顶部导航栏 */}
+
+
+
+
+ {t('gold.realtimeQuote')}
+
+
+
+ {currentTime}
+
+
+
+
+
+
+ {/* 主内容区域 */}
+
+ {/* 价格 */}
+
+ {/* 国内黄金 */}
+
+
+
+
+
+ {t('gold.domesticGold')}CNY
+
+ {formatPrice(priceData.domestic?.price, 'CNY')}
+
+
+ {formatChange(priceData.domestic?.change)} ({formatPercent(priceData.domestic?.changePercent)})
+
+
+
+
+ {t('gold.open')}
+ {priceData.domestic?.open?.toFixed(2) || '--'}
+
+
+ {t('gold.high')}
+ {priceData.domestic?.high?.toFixed(2) || '--'}
+
+
+ {t('gold.low')}
+ {priceData.domestic?.low?.toFixed(2) || '--'}
+
+
+ {t('gold.volume')}
+ {priceData.domestic?.volume || '--'}
+
+
+
+
+ {/* 国际金价 */}
+
+
+
+
+
+ {t('gold.internationalGold')}SPOT
+
+ {formatPrice(priceData.international?.price, 'USD')}
+
+
+ {formatChange(priceData.international?.change)} ({formatPercent(priceData.international?.changePercent)})
+
+
+
+
+ {t('gold.prevClose')}
+ {priceData.international?.previousClose?.toFixed(2) || '--'}
+
+
+ {t('gold.silver')}
+ {priceData.international?.silverPrice?.toFixed(2) || '--'}
+
+
+
+
+
+ {/* K线图区域 */}
+
+
+
+
+ {t('gold.priceTrend')}
+
+
+ {t('gold.tradingviewWarning')}
+
+
+
+
+
+
+
+ {/* 全屏水印 */}
+
+
+ {Array.from({ length: 20 }).map((_, index) => (
+
+ ChattyPlay
+ P1Kaj1uu
+ 不见水星记
+
+ ))}
+
+
+
+ {/* 订阅弹窗 */}
+ {t('gold.subscribeTitle')} }
+ open={subscribeModalVisible}
+ onCancel={() => {
+ setSubscribeModalVisible(false)
+ setEmailInput('')
+ }}
+ footer={[
+ ,
+ ,
+ ]}
+ >
+
+
+ {t('gold.subscribeDescription')}
+
+ setEmailInput(e.target.value)}
+ onPressEnter={handleSubscribe}
+ size="large"
+ type="email"
+ />
+
+
+
+ )
+}
+
+export default Gold
diff --git a/src/pages/Help.tsx b/src/pages/Help.tsx
new file mode 100644
index 0000000..90c41ad
--- /dev/null
+++ b/src/pages/Help.tsx
@@ -0,0 +1,199 @@
+import React from 'react'
+import { Card, Row, Col, Typography } from 'antd'
+import {
+ MessageOutlined,
+ VideoCameraOutlined,
+ SoundOutlined,
+ TranslationOutlined,
+ PictureOutlined,
+ QuestionCircleOutlined
+} from '@ant-design/icons'
+import { useTranslation } from 'react-i18next'
+import styled from 'styled-components'
+
+const { Title, Paragraph } = Typography
+
+const PageContainer = styled.div`
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 40px 20px;
+ min-height: calc(100vh - 200px);
+`
+
+const SectionTitle = styled.h2`
+ margin-bottom: 40px;
+ text-align: center;
+ font-size: 2rem;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+`
+
+const HelpCard = styled(Card)`
+ border-radius: 12px;
+ border: none;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+ transition: all 0.3s ease;
+ height: 100%;
+
+ &:hover {
+ transform: translateY(-5px);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
+ }
+
+ .ant-card-head {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: #fff;
+ border-radius: 12px 12px 0 0;
+ }
+`
+
+const HelpIcon = styled.div`
+ font-size: 2.5rem;
+ color: #667eea;
+ margin-bottom: 16px;
+`
+
+const Help: React.FC = () => {
+ const { t } = useTranslation()
+ const helpSections = [
+ {
+ title: t('help.chatgpt.title'),
+ description: t('help.chatgpt.description'),
+ icon: ,
+ steps: [
+ t('help.chatgpt.step1'),
+ t('help.chatgpt.step2'),
+ t('help.chatgpt.step3'),
+ t('help.chatgpt.step4')
+ ]
+ },
+ {
+ title: t('help.video.title'),
+ description: t('help.video.description'),
+ icon: ,
+ steps: [
+ t('help.video.step1'),
+ t('help.video.step2'),
+ t('help.video.step3'),
+ t('help.video.step4')
+ ]
+ },
+ {
+ title: t('help.music.title'),
+ description: t('help.music.description'),
+ icon: ,
+ steps: [
+ t('help.music.step1'),
+ t('help.music.step2'),
+ t('help.music.step3'),
+ t('help.music.step4')
+ ]
+ },
+ {
+ title: t('help.trans.title'),
+ description: t('help.trans.description'),
+ icon: ,
+ steps: [
+ t('help.trans.step1'),
+ t('help.trans.step2'),
+ t('help.trans.step3'),
+ t('help.trans.step4')
+ ]
+ },
+ {
+ title: t('help.textToPhoto.title'),
+ description: t('help.textToPhoto.description'),
+ icon: ,
+ steps: [
+ t('help.textToPhoto.step1'),
+ t('help.textToPhoto.step2'),
+ t('help.textToPhoto.step3'),
+ t('help.textToPhoto.step4'),
+ t('help.textToPhoto.step5')
+ ]
+ },
+ {
+ title: t('help.faq.title'),
+ description: t('help.faq.description'),
+ icon: ,
+ steps: [
+ t('help.faq.step1'),
+ t('help.faq.step2'),
+ t('help.faq.step3'),
+ t('help.faq.step4')
+ ]
+ }
+ ]
+
+ return (
+
+ {t('help.title')}
+
+ {helpSections.map((section, index) => (
+
+
+
+ {section.icon}
+ {section.title}
+
+ }
+ description={
+
+
+ {section.description}
+
+
+ {t('help.stepsTitle')}
+
+
+ {section.steps.map((step, stepIndex) => (
+ -
+
+ {stepIndex + 1}
+
+ {step}
+
+ ))}
+
+
+ }
+ />
+
+
+ ))}
+
+
+ )
+}
+
+export default Help
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
new file mode 100644
index 0000000..34687ad
--- /dev/null
+++ b/src/pages/Home.tsx
@@ -0,0 +1,348 @@
+import React from 'react'
+import { Typography, Row, Col, Card } from 'antd'
+import {
+ SoundOutlined,
+ VideoCameraOutlined,
+ TranslationOutlined,
+ MessageOutlined,
+ PictureOutlined,
+ QuestionCircleOutlined,
+ ReadOutlined,
+ BookOutlined,
+ GoldOutlined,
+ AndroidOutlined,
+ RobotOutlined,
+ NodeIndexOutlined
+} from '@ant-design/icons'
+import { useNavigate } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import styled from 'styled-components'
+import { logoImage } from '@/utils/images'
+
+const { Title, Paragraph } = Typography
+
+const HomeContainer = styled.div`
+ width: 100%;
+ min-height: calc(100vh - 64px);
+ background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
+ position: relative;
+ overflow: hidden;
+
+ /* 背景动画效果 */
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: radial-gradient(circle at 20% 50%, rgba(102, 126, 234, 0.05) 0%, transparent 50%),
+ radial-gradient(circle at 80% 50%, rgba(118, 75, 162, 0.05) 0%, transparent 50%);
+ animation: backgroundPulse 15s ease-in-out infinite;
+ pointer-events: none;
+ }
+
+ @keyframes backgroundPulse {
+ 0%, 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ 50% {
+ opacity: 0.8;
+ transform: scale(1.05);
+ }
+ }
+`
+
+const ContentWrapper = styled.div`
+ position: relative;
+ z-index: 1;
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 40px 24px;
+ width: 100%;
+ box-sizing: border-box;
+
+ @media (max-width: 768px) {
+ padding: 24px 16px;
+ }
+`
+
+const WelcomeSection = styled.div`
+ text-align: center;
+ padding: 40px 20px;
+ margin-bottom: 40px;
+ animation: fadeInDown 0.8s ease-out;
+
+ @keyframes fadeInDown {
+ from {
+ opacity: 0;
+ transform: translateY(-30px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
+ .logo {
+ width: 80px;
+ height: 80px;
+ border-radius: 50%;
+ margin-bottom: 20px;
+ animation: logoFloat 3s ease-in-out infinite;
+ }
+
+ @keyframes logoFloat {
+ 0%, 100% {
+ transform: translateY(0) rotate(0deg);
+ }
+ 50% {
+ transform: translateY(-10px) rotate(5deg);
+ }
+ }
+
+ @media (max-width: 768px) {
+ padding: 24px 16px;
+ }
+
+ h1 {
+ font-size: clamp(1.8rem, 5vw, 2.5rem) !important;
+ }
+
+ p {
+ font-size: clamp(1rem, 2vw, 1.2rem) !important;
+ }
+`
+
+const ContentCard = styled(Card)`
+ border-radius: 16px;
+ border: none;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+ transition: all 0.3s ease;
+ height: 100%;
+ cursor: pointer;
+ overflow: hidden;
+ position: relative;
+ width: 100%;
+
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 4px;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ transform: scaleX(0);
+ transition: transform 0.3s ease;
+ }
+
+ &:hover::before {
+ transform: scaleX(1);
+ }
+
+ &:hover {
+ transform: translateY(-8px);
+ box-shadow: 0 12px 32px rgba(102, 126, 234, 0.2);
+ }
+
+ .ant-card-body {
+ padding: 24px;
+
+ @media (max-width: 768px) {
+ padding: 20px;
+ }
+ }
+`
+
+const FeatureIcon = styled.div`
+ font-size: 3rem;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ margin-bottom: 16px;
+ transition: transform 0.3s ease;
+
+ @media (max-width: 768px) {
+ font-size: 2.5rem;
+ }
+`
+
+const Home: React.FC = () => {
+ const { t } = useTranslation()
+ const navigate = useNavigate()
+
+ const features = [
+ {
+ title: t('home.features.music.title'),
+ description: t('home.features.music.description'),
+ icon: ,
+ path: '/music'
+ },
+ {
+ title: t('home.features.video.title'),
+ description: t('home.features.video.description'),
+ icon: ,
+ path: '/video'
+ },
+ {
+ title: t('home.features.papers.title'),
+ description: t('home.features.papers.description'),
+ icon: ,
+ path: '/papers'
+ },
+ {
+ title: t('home.features.goofish.title'),
+ description: t('home.features.goofish.description'),
+ icon: ,
+ path: '/goofish'
+ },
+ {
+ title: t('home.features.chatgpt.title'),
+ description: t('home.features.chatgpt.description'),
+ icon: ,
+ path: '/gpt'
+ },
+ {
+ title: t('home.features.markmap.title'),
+ description: t('home.features.markmap.description'),
+ icon: ,
+ path: '/markmap'
+ },
+ {
+ title: t('home.features.gold.title'),
+ description: t('home.features.gold.description'),
+ icon: ,
+ path: '/gold'
+ },
+ {
+ title: t('home.features.cartoon.title'),
+ description: t('home.features.cartoon.description'),
+ icon: ,
+ path: '/cartoon'
+ },
+ {
+ title: t('home.features.worker.title'),
+ description: t('home.features.worker.description'),
+ icon: ,
+ path: '/worker'
+ },
+ {
+ title: t('home.features.textToPhoto.title'),
+ description: t('home.features.textToPhoto.description'),
+ icon: ,
+ path: '/text-to-photo'
+ },
+ {
+ title: t('home.features.trans.title'),
+ description: t('home.features.trans.description'),
+ icon: ,
+ path: '/trans'
+ },
+ {
+ title: t('home.features.help.title'),
+ description: t('home.features.help.description'),
+ icon: ,
+ path: '/help'
+ }
+ ]
+
+ return (
+
+
+
+
+
+ {t('home.welcome')}
+
+
+ {t('home.description')}
+
+
+
+
+ {features.map((feature, index) => (
+
+ navigate(feature.path)}
+ style={{ animation: 'fadeInUp 0.6s ease-out forwards', opacity: 0 }}
+ >
+ {feature.icon}
+
+ {feature.title}
+
+
+ {feature.description}
+
+
+
+ ))}
+
+
+
+
+ {t('home.footer.slogan')}
+
+
+ {t('home.footer.thanks')}
+
+
+
+
+
+
+ )
+}
+
+export default Home
diff --git a/src/pages/Latex.tsx b/src/pages/Latex.tsx
new file mode 100644
index 0000000..d98f875
--- /dev/null
+++ b/src/pages/Latex.tsx
@@ -0,0 +1,714 @@
+import React, { useState, useEffect, useCallback, useRef } from 'react'
+import { Button, message, Modal, Tooltip, Select, Radio } from 'antd'
+import { PlayCircleOutlined, FullscreenOutlined, FileTextOutlined, EditOutlined, EyeOutlined } from '@ant-design/icons'
+import FileTree, { type FileTreeRef, type FileNode } from '@/components/latex/FileTree'
+import LatexEditor from '@/components/latex/LatexEditor'
+import PdfPreview from '@/components/latex/PdfPreview'
+import { compileLatex } from '@/services/latexApi'
+
+// 英文模板
+const ENGLISH_TEMPLATE = `\\documentclass{article}
+\\usepackage[utf8]{inputenc}
+\\usepackage{amsmath}
+\\usepackage{graphicx}
+\\usepackage{float}
+\\title{My First LaTeX Document}
+\\author{Your Name}
+\\date{\\today}
+
+\\begin{document}
+
+\\maketitle
+
+\\section{Introduction}
+This is a sample LaTeX document for testing.
+
+\\section{Mathematical Formulas}
+The famous Euler's formula:
+\\begin{equation}
+e^{i\\pi} + 1 = 0
+\\end{equation}
+
+Einstein's mass-energy equivalence:
+\\begin{equation}
+E = mc^2
+\\end{equation}
+
+\\section{Lists}
+\\begin{itemize}
+ \\item First item
+ \\item Second item
+ \\item Third item
+\\end{itemize}
+
+\\section{Conclusion}
+This document demonstrates some basic LaTeX features.
+
+\\end{document}`
+
+// 中文模板
+const CHINESE_TEMPLATE = `\\documentclass[UTF8]{ctexart}
+\\usepackage{xeCJK}
+\\setCJKmainfont{Noto Sans CJK SC}
+\\usepackage{graphicx}
+\\usepackage{float}
+\\title{我的第一篇 LaTeX 文档}
+\\author{Your Name}
+\\date{\\today}
+
+\\begin{document}
+
+\\maketitle
+
+\\section{简介}
+这是使用 LaTeX 编写的示例文档。
+
+\\section{数学公式}
+著名的欧拉公式:
+\\begin{equation}
+e^{i\\pi} + 1 = 0
+\\end{equation}
+
+爱因斯坦质能方程:
+\\begin{equation}
+E = mc^2
+\\end{equation}
+
+\\section{列表}
+\\begin{itemize}
+ \\item 第一项
+ \\item 第二项
+ \\item 第三项
+\\end{itemize}
+
+\\section{结论}
+本文档展示了 LaTeX 的一些基本功能。
+
+\\end{document}`
+
+const Latex: React.FC = () => {
+ // 状态管理
+ const [selectedFileKey, setSelectedFileKey] = useState('main')
+ const [selectedFileName, setSelectedFileName] = useState('main.tex')
+ const [editorContent, setEditorContent] = useState('')
+ const [pdfBlob, setPdfBlob] = useState(null)
+ const [isCompiling, setIsCompiling] = useState(false)
+ const [compileError, setCompileError] = useState(null)
+ const [isFullscreen, setIsFullscreen] = useState(false)
+ const [compiler, setCompiler] = useState<'xelatex' | 'pdflatex' | 'lualatex'>('xelatex')
+ const [isImage, setIsImage] = useState(false)
+ const [imageUrl, setImageUrl] = useState('')
+ const [imageFiles, setImageFiles] = useState>([])
+
+ // 移动端视图切换:'files' | 'editor' | 'preview'
+ const [mobileView, setMobileView] = useState<'files' | 'editor' | 'preview'>('editor')
+
+ // 防抖定时器
+ const debounceTimerRef = useRef(null)
+ // FileTree ref,用于调其 replaceTree
+ const fileTreeRef = useRef(null)
+
+ // 初始加载默认内容
+ useEffect(() => {
+ // 持久化模板到 localStorage(zip 上传会清空,这里每次进入页面重置一次)
+ localStorage.setItem('latex-chinese-template', CHINESE_TEMPLATE)
+ localStorage.setItem('latex-english-template', ENGLISH_TEMPLATE)
+
+ // 检查是否有保存的文件内容,如果有则加载,否则使用模板
+ const savedContent = localStorage.getItem('latex-file-main')
+ if (savedContent) {
+ setEditorContent(savedContent)
+ } else {
+ setEditorContent(ENGLISH_TEMPLATE)
+ }
+ }, [])
+
+ // 编译 LaTeX
+ const handleCompile = useCallback(async (code?: string) => {
+ const codeToCompile = code ?? editorContent
+
+ if (!codeToCompile.trim()) {
+ message.warning('请输入 LaTeX 代码')
+ return
+ }
+
+ setIsCompiling(true)
+ setCompileError(null)
+ setPdfBlob(null) // 清除旧的PDF缓存
+
+ try {
+ // 将图片文件转换为 base64 格式
+ const filesArray = await Promise.all(
+ imageFiles.map(async (img) => {
+ const base64 = await blobToBase64(img.blob)
+ return {
+ name: img.name,
+ content: base64
+ }
+ })
+ )
+
+ console.log('编译时包含的图片文件:', imageFiles.map(f => f.name))
+
+ const result = await compileLatex({ code: codeToCompile, compiler, files: filesArray })
+
+ if (result.success && result.pdfBlob) {
+ setPdfBlob(result.pdfBlob)
+ message.success('编译成功')
+
+ // 编译成功后自动切换到预览视图(移动端)
+ if (window.innerWidth < 700) {
+ setMobileView('preview')
+ }
+ } else {
+ // 不向后端原始错误暴露给用户,统一展示友好提示
+ console.warn('LaTeX 编译失败(后端返回):', result.error)
+ const friendlyError = '当前 LaTeX 编译超时,请联系管理员开通会员'
+ setCompileError(friendlyError)
+ message.error(friendlyError)
+ }
+ } catch (err) {
+ // 异常情况同样不暴露原始错误
+ console.warn('LaTeX 编译请求异常:', err)
+ const friendlyError = '当前 LaTeX 编译超时,请联系管理员开通会员'
+ setCompileError(friendlyError)
+ message.error(friendlyError)
+ } finally {
+ setIsCompiling(false)
+ }
+ }, [editorContent, compiler, imageFiles])
+
+ // 辅助函数:将 Blob 转换为 base64 格式
+ const blobToBase64 = (blob: Blob): Promise => {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onloadend = () => {
+ // 保留完整的 data URI 格式
+ resolve(reader.result as string)
+ }
+ reader.onerror = reject
+ reader.readAsDataURL(blob)
+ })
+ }
+
+ // 文件树选择
+ const handleSelectFile = useCallback((key: string, content: string, imageBlob?: Blob) => {
+ console.log('imageBlob:', imageBlob)
+ // 先保存当前编辑内容到 localStorage
+ if (selectedFileKey && editorContent) {
+ localStorage.setItem(`latex-file-${selectedFileKey}`, editorContent)
+ }
+
+ setSelectedFileKey(key)
+
+ // 检查是否为图片文件(以 IMAGE: 开头)
+ if (content.startsWith('IMAGE:')) {
+ // 是图片文件
+ const imageData = content.substring(6) // 去掉 "IMAGE:" 前缀
+ setIsImage(true)
+ setImageUrl(imageData)
+ setEditorContent('') // 清空编辑器内容
+ setSelectedFileName(key.split('-').pop() || 'image.png')
+ } else {
+ // 是文本文件
+ setIsImage(false)
+ setImageUrl('')
+
+ // 从 localStorage 读取文件内容
+ const savedContent = localStorage.getItem(`latex-file-${key}`)
+ const fileContent = savedContent ?? content
+
+ setEditorContent(fileContent)
+ console.log('fileContent', key)
+ setSelectedFileName('文本文件')
+ }
+
+ // 移动端选择文件后切换到编辑器视图
+ if (window.innerWidth < 700) {
+ setMobileView('editor')
+ }
+ }, [selectedFileKey, editorContent])
+
+ // 在文件树中查找指定标题的文件
+ const findNodeByTitle = useCallback((nodes: FileNode[], title: string): FileNode | null => {
+ for (const n of nodes) {
+ if (n.title.toLowerCase() === title) return n
+ if (n.children) {
+ const r = findNodeByTitle(n.children, title)
+ if (r) return r
+ }
+ }
+ return null
+ }, [])
+
+ // 找到第一个 .tex 文件
+ const findFirstTex = useCallback((nodes: FileNode[]): FileNode | null => {
+ for (const n of nodes) {
+ if (n.type === 'tex') return n
+ if (n.children) {
+ const r = findFirstTex(n.children)
+ if (r) return r
+ }
+ }
+ return null
+ }, [])
+
+ // 处理 zip 上传完成后的替代流程
+ const handleZipUploaded = useCallback((rootNodes: FileNode[]) => {
+ // 0. 先校验:解压后必须存在 .tex 文件,否则弹窗提示并不做任何改动
+ const entry =
+ findNodeByTitle(rootNodes, 'main.tex') ||
+ findNodeByTitle(rootNodes, 'index.tex') ||
+ findFirstTex(rootNodes)
+
+ if (!entry) {
+ Modal.warning({
+ title: '格式不正确',
+ content: '请上传符合 LaTeX 格式的文件夹压缩包',
+ okText: '我知道了',
+ })
+ return
+ }
+
+ // 1. 清理所有相关的 localStorage(保留模板本身,模板从常量读取)
+ const keysToRemove: string[] = []
+ for (let i = 0; i < localStorage.length; i++) {
+ const k = localStorage.key(i)
+ if (
+ k &&
+ (k.startsWith('latex-file-') ||
+ k.startsWith('latex-image-') ||
+ k === 'latex-file-tree-v3')
+ ) {
+ keysToRemove.push(k)
+ }
+ }
+ keysToRemove.forEach((k) => localStorage.removeItem(k))
+
+ // 2. 用 FileTree 的 replaceTree 替代文件树
+ fileTreeRef.current?.replaceTree(rootNodes)
+
+ // 3. 重置本地状态
+ setImageFiles([])
+ setPdfBlob(null)
+ setCompileError(null)
+ setIsImage(false)
+ setImageUrl('')
+ setSelectedFileKey(null)
+ setSelectedFileName('')
+ setEditorContent('')
+
+ // 4. 选中入口文件并加载
+ if (window.innerWidth < 700) {
+ setMobileView('editor')
+ }
+
+ if (entry.type === 'image') {
+ const url = entry.imageUrl || (entry.imageBlob ? URL.createObjectURL(entry.imageBlob) : '')
+ setSelectedFileKey(entry.key)
+ setSelectedFileName(entry.title)
+ setIsImage(true)
+ setImageUrl(url)
+ } else {
+ const content = entry.content || ''
+ setSelectedFileKey(entry.key)
+ setSelectedFileName(entry.title)
+ setEditorContent(content)
+ }
+ message.success(`已加载压缩包,入口:${entry.title}`)
+ }, [findNodeByTitle, findFirstTex])
+
+ // 应用模板:清空文件树,重建为单个 main.tex 并展示模板内容
+ const applyTemplate = useCallback((templateKey: 'english' | 'chinese') => {
+ const content = templateKey === 'english' ? ENGLISH_TEMPLATE : CHINESE_TEMPLATE
+
+ // 1. 清理所有相关的 localStorage(保留模板本身)
+ const keysToRemove: string[] = []
+ for (let i = 0; i < localStorage.length; i++) {
+ const k = localStorage.key(i)
+ if (
+ k &&
+ (k.startsWith('latex-file-') ||
+ k.startsWith('latex-image-') ||
+ k === 'latex-file-tree-v3')
+ ) {
+ keysToRemove.push(k)
+ }
+ }
+ keysToRemove.forEach((k) => localStorage.removeItem(k))
+
+ // 2. 用单个 main.tex 替代文件树
+ const newNode: FileNode = {
+ key: 'main',
+ title: 'main.tex',
+ type: 'tex',
+ content,
+ }
+ fileTreeRef.current?.replaceTree([newNode])
+
+ // 3. 重置本地状态
+ setImageFiles([])
+ setPdfBlob(null)
+ setCompileError(null)
+ setIsImage(false)
+ setImageUrl('')
+
+ // 4. 选中并加载模板到编辑器
+ setSelectedFileKey('main')
+ setSelectedFileName('main.tex')
+ setEditorContent(content)
+ localStorage.setItem('latex-file-main', content)
+
+ if (window.innerWidth < 700) {
+ setMobileView('editor')
+ }
+
+ if (templateKey === 'english') {
+ message.success('已切换到英文模板')
+ } else {
+ message.warning('中文模板:编译可能需要更多时间,且显示效果取决于服务器字体支持')
+ }
+ }, [])
+
+ // 全屏切换
+ const toggleFullscreen = () => {
+ if (!document.fullscreenElement) {
+ document.documentElement.requestFullscreen()
+ setIsFullscreen(true)
+ } else {
+ document.exitFullscreen()
+ setIsFullscreen(false)
+ }
+ }
+
+ // 清理定时器
+ useEffect(() => {
+ return () => {
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current)
+ }
+ }
+ }, [])
+
+ // 监听窗口大小变化
+ const [isMobile, setIsMobile] = useState(window.innerWidth < 700)
+
+ useEffect(() => {
+ const handleResize = () => {
+ setIsMobile(window.innerWidth < 700)
+ }
+ window.addEventListener('resize', handleResize)
+ return () => window.removeEventListener('resize', handleResize)
+ }, [])
+
+ // 自动保存文件内容到 localStorage
+ useEffect(() => {
+ if (selectedFileKey && editorContent && !isImage) {
+ localStorage.setItem(`latex-file-${selectedFileKey}`, editorContent)
+ }
+ }, [selectedFileKey, editorContent, isImage])
+
+ return (
+
+ {/* 顶部工具栏 */}
+
+
+
+ LaTeX 编辑器
+
+ {!isMobile && selectedFileName && (
+
+ {selectedFileName}
+
+ )}
+
+
+
+ {!isMobile && (
+ <>
+
+
+
+ >
+ )}
+
+
+
+ }
+ onClick={() => handleCompile()}
+ loading={isCompiling}
+ size={isMobile ? "small" : "middle"}
+ style={{
+ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
+ border: 'none',
+ }}
+ >
+ 编译
+
+
+
+ }
+ onClick={toggleFullscreen}
+ size={isMobile ? "small" : "middle"}
+ />
+
+
+
+
+ {/* 三栏布局 - 响应式 */}
+
+ {/* 左侧文件树 - PC端固定显示 */}
+ {!isMobile && (
+
+
+
+ )}
+
+ {/* 移动端文件树视图 */}
+ {isMobile && mobileView === 'files' && (
+
+
+
+ )}
+
+ {/* 中间编辑器 - PC端固定显示,移动端根据视图切换 */}
+ {!isMobile && (
+
+ {isImage ? (
+
+
+
+ ) : (
+
+ )}
+
+ )}
+
+ {/* 移动端编辑器视图 */}
+ {isMobile && mobileView === 'editor' && (
+
+ {isImage ? (
+
+
+
+ ) : (
+
+ )}
+
+ )}
+
+ {/* 右侧 PDF 预览 - PC端固定显示 */}
+ {!isMobile && (
+
+ handleCompile()}
+ downloadFileName="Latex-Document.pdf"
+ />
+
+ )}
+
+ {/* 移动端预览视图 */}
+ {isMobile && mobileView === 'preview' && (
+
+ handleCompile()}
+ downloadFileName="Latex-Document.pdf"
+ />
+
+ )}
+
+
+ {/* 移动端底部导航栏 */}
+ {isMobile && (
+
+ setMobileView(e.target.value)}
+ optionType="button"
+ buttonStyle="solid"
+ size="small"
+ className="bg-white rounded-lg flex w-full"
+ style={{ display: 'flex', width: '100%' }}
+ >
+
+ 文件
+
+
+ 编辑
+
+
+ 预览
+
+
+
+ )}
+
+ )
+}
+
+export default Latex
\ No newline at end of file
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx
new file mode 100644
index 0000000..e1ea9f9
--- /dev/null
+++ b/src/pages/Login.tsx
@@ -0,0 +1,1011 @@
+import React, { useState, useEffect, useRef } from 'react'
+import { Form, Input, Button, Checkbox, message, Space } from 'antd'
+import {
+ UserOutlined,
+ LockOutlined,
+ KeyOutlined,
+ EyeOutlined,
+ EyeInvisibleOutlined,
+} from '@ant-design/icons'
+import { useGoogleLogin } from '@react-oauth/google'
+import { FcGoogle } from 'react-icons/fc'
+import { FaGithub } from 'react-icons/fa'
+import { useNavigate } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import styled from 'styled-components'
+import VerifyCode from '../components/VerifyCode'
+import HeartBeat from '../components/HeartBeat'
+import { logoImage } from '@/utils/images'
+import { setToken } from '@/utils/token'
+import { isMobileDevice } from '@/utils/isMobile'
+
+// Cloudflare Turnstile 配置
+const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
+
+// 创建气泡组件
+const Bubble = styled.div<{ size: number; left: number; delay: number; popped: boolean }>`
+ position: absolute;
+ bottom: -100px;
+ left: ${props => props.left}%;
+ width: ${props => props.size}px;
+ height: ${props => props.size}px;
+ background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.3));
+ border-radius: 50%;
+ animation: rise ${props => 4 + props.delay}s ease-in infinite;
+ opacity: 0;
+ pointer-events: none;
+ transition: transform 0.1s ease-out, opacity 0.1s ease-out;
+ ${props => props.popped && `
+ animation: none;
+ transform: scale(0);
+ opacity: 0;
+ `}
+
+ @keyframes rise {
+ 0% {
+ bottom: -100px;
+ opacity: 0;
+ transform: translateX(0) scale(1);
+ }
+ 10% {
+ opacity: 0.8;
+ }
+ 90% {
+ opacity: 0.8;
+ transform: translateX(${props => Math.sin(props.delay * 10) * 30}px) scale(1);
+ }
+ 100% {
+ bottom: 100vh;
+ opacity: 0;
+ transform: translateX(${props => Math.sin(props.delay * 10) * 50}px) scale(1.2);
+ }
+ }
+
+ /* 气泡光泽效果 */
+ &::after {
+ content: '';
+ position: absolute;
+ top: 15%;
+ left: 20%;
+ width: 30%;
+ height: 30%;
+ background: rgba(255, 255, 255, 0.6);
+ border-radius: 50%;
+ filter: blur(2px);
+ }
+`
+
+const LoginContainer = styled.div`
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+ background: linear-gradient(45deg, #667eea, #764ba2, #f093fb, #f5576c, #667eea);
+ background-size: 400% 400%;
+ animation: gradientShift 15s ease infinite;
+ position: relative;
+ overflow: hidden;
+
+ @keyframes gradientShift {
+ 0% { background-position: 0% 50%; }
+ 50% { background-position: 100% 50%; }
+ 100% { background-position: 0% 50%; }
+ }
+
+ /* 动态光晕效果 */
+ &::before {
+ content: '';
+ position: absolute;
+ top: -50%;
+ left: -50%;
+ width: 200%;
+ height: 200%;
+ background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
+ background-size: 60px 60px;
+ animation: moveBackground 30s linear infinite;
+ }
+
+ @keyframes moveBackground {
+ 0% { transform: translate(0, 0) rotate(0deg); }
+ 100% { transform: translate(60px, 60px) rotate(360deg); }
+ }
+`
+
+const LoginBox = styled.div`
+ width: 100%;
+ max-width: 400px;
+ background: rgba(255, 255, 255, 0.95);
+ backdrop-filter: blur(10px);
+ border-radius: 20px;
+ box-shadow:
+ 0 20px 60px rgba(0, 0, 0, 0.3),
+ 0 0 0 1px rgba(255, 255, 255, 0.5) inset;
+ position: relative;
+ z-index: 1;
+ padding: 12px 32px;
+ animation: boxAppear 0.6s cubic-bezier(0.16, 1, 0.3, 1);
+
+ @keyframes boxAppear {
+ from {
+ opacity: 0;
+ transform: translateY(30px) scale(0.9);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+ }
+
+ @media (max-width: 480px) {
+ max-width: 100%;
+ padding: 24px;
+ }
+`
+
+const Logo = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-bottom: 24px;
+
+ img {
+ width: 60px;
+ height: 60px;
+ border-radius: 50%;
+ margin-bottom: 12px;
+ animation: logoAnimation 3s ease-in-out infinite;
+ box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
+ }
+
+ @keyframes logoAnimation {
+ 0%, 100% {
+ transform: rotate(0deg) scale(1);
+ }
+ 50% {
+ transform: rotate(180deg) scale(1.05);
+ }
+ }
+
+ h3 {
+ margin: 0;
+ font-size: 1.3rem;
+ font-weight: bold;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ letter-spacing: 0.1em;
+ }
+`
+
+const OAuthDivider = styled.div`
+ display: flex;
+ align-items: center;
+ margin: 24px 0;
+ color: #94a3b8;
+ font-size: 0.875rem;
+
+ &::before,
+ &::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: linear-gradient(to right, transparent, #e2e8f0, transparent);
+ }
+
+ &::before {
+ margin-right: 16px;
+ }
+
+ &::after {
+ margin-left: 16px;
+ }
+`
+
+const OAuthButtonContainer = styled.div`
+ display: flex;
+ gap: 12px;
+ margin-bottom: 16px;
+`
+
+const OAuthButton = styled.button<{ variant: 'google' | 'github' }>`
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ height: 44px;
+ border: 1px solid ${props => props.variant === 'google' ? '#dadce0' : '#d1d5db'};
+ border-radius: 8px;
+ background: ${props => props.variant === 'google' ? '#fff' : '#24292e'};
+ color: ${props => props.variant === 'github' ? '#fff' : '#3c4043'};
+ font-size: 0.9rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ padding: 0 16px;
+
+ &:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px ${props => props.variant === 'google' ? 'rgba(60, 64, 67, 0.3)' : 'rgba(36, 41, 46, 0.3)'};
+ border-color: ${props => props.variant === 'google' ? '#d3d3d3' : '#1a202c'};
+ }
+
+ &:active {
+ transform: translateY(0);
+ }
+
+ svg {
+ width: 20px;
+ height: 20px;
+ }
+`
+
+const ModeSwitch = styled.div`
+ text-align: center;
+ margin-top: 16px;
+ color: #667eea;
+ cursor: pointer;
+ font-size: 0.9rem;
+ transition: color 0.2s;
+
+ &:hover {
+ color: #764ba2;
+ text-decoration: underline;
+ }
+`
+
+// 添加 Turnstile 脚本加载
+const loadTurnstileScript = (): Promise => {
+ return new Promise((resolve, reject) => {
+ // 如果已经加载完成
+ if (window.turnstile) {
+ resolve()
+ return
+ }
+
+ // 检查是否已有脚本标签
+ const existingScript = document.querySelector('script[src*="challenges.cloudflare.com/turnstile"]')
+ if (existingScript) {
+ existingScript.addEventListener('load', () => resolve())
+ existingScript.addEventListener('error', () => reject())
+ return
+ }
+
+ const script = document.createElement('script')
+ script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
+ script.async = true
+ script.defer = true
+ script.onload = () => resolve()
+ script.onerror = () => reject(new Error('Failed to load Turnstile script'))
+ document.head.appendChild(script)
+ })
+}
+
+// 保存 OAuth 登录会话(本站 JWT + 用户信息)
+const applyOAuthSession = (data: { token: string; user: { id: number; username: string; email?: string; avatar?: string } }) => {
+ setToken(data.token)
+ localStorage.setItem('isLoggedIn', 'true')
+ localStorage.setItem('username', data.user.username)
+ localStorage.setItem('userId', String(data.user.id))
+ if (data.user.email) {
+ localStorage.setItem('email', data.user.email)
+ }
+ if (data.user.avatar) {
+ localStorage.setItem('avatar', data.user.avatar)
+ }
+}
+
+const Login: React.FC = () => {
+ const { t } = useTranslation()
+ const [loginForm] = Form.useForm()
+ const [registerForm] = Form.useForm()
+ const [verifyCode, setVerifyCode] = useState('')
+ const [loading, setLoading] = useState(false)
+ const [isAgreed, setIsAgreed] = useState(true)
+ const [showHeartBeat, setShowHeartBeat] = useState(false)
+ const [isLoginMode, setIsLoginMode] = useState(true)
+ const [turnstileReady, setTurnstileReady] = useState(false)
+ const turnstileLoginRef = useRef(null)
+ const turnstileRegisterRef = useRef(null)
+ const turnstileWidgetId = useRef<{ login: string | null; register: string | null }>({ login: null, register: null })
+ const navigate = useNavigate()
+ const [bubbles, setBubbles] = useState(() =>
+ Array.from({ length: 15 }, () => ({
+ size: Math.random() * 30 + 10,
+ left: Math.random() * 100,
+ delay: Math.random() * 3,
+ popped: false
+ }))
+ )
+
+ useEffect(() => {
+ generateVerifyCode()
+
+ // 气泡破裂效果
+ const popBubbles = setInterval(() => {
+ setBubbles(prev => {
+ const popIndex = Math.floor(Math.random() * prev.length)
+ const newBubbles = [...prev]
+
+ // 破裂动画
+ newBubbles[popIndex] = { ...newBubbles[popIndex], popped: true }
+
+ // 重新生成该气泡
+ setTimeout(() => {
+ setBubbles(current => {
+ const reset = [...current]
+ reset[popIndex] = {
+ size: Math.random() * 30 + 10,
+ left: Math.random() * 100,
+ delay: Math.random() * 3,
+ popped: false
+ }
+ return reset
+ })
+ }, 100)
+
+ return newBubbles
+ })
+ }, 2000)
+
+ return () => clearInterval(popBubbles)
+ }, [])
+
+ // 加载 Turnstile 脚本
+ useEffect(() => {
+ // 如果没有配置站点密钥,跳过加载
+ if (!TURNSTILE_SITE_KEY) {
+ console.warn('Turnstile site key not configured, skipping verification')
+ return
+ }
+
+ loadTurnstileScript()
+ .then(() => {
+ setTurnstileReady(true)
+ })
+ .catch((error) => {
+ console.warn('Failed to load Turnstile:', error)
+ // Turnstile 加载失败,允许用户继续登录(静默降级)
+ setTurnstileReady(true) // 设置为 true 避免一直等待
+ })
+ }, [])
+
+ // GitHub OAuth 回调处理:URL 带 code 时用 code 向后端换取本站 JWT
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search)
+ const githubCode = params.get('code')
+ if (!githubCode) return
+
+ window.history.replaceState({}, '', window.location.pathname)
+ setLoading(true)
+ ;(async () => {
+ try {
+ const response = await fetch('/api/auth/github', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ code: githubCode })
+ })
+ const data = await response.json()
+ if (data.success) {
+ applyOAuthSession(data)
+ message.success(t('login.loginSuccess'))
+ setShowHeartBeat(true)
+ } else {
+ message.error(data.message || t('login.loginFailed'))
+ }
+ } catch (error: any) {
+ console.error('GitHub OAuth error:', error)
+ message.error(error.message || t('login.loginFailed'))
+ } finally {
+ setLoading(false)
+ }
+ })()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ const generateVerifyCode = () => {
+ const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'
+ let code = ''
+ for (let i = 0; i < 4; i++) {
+ code += chars.charAt(Math.floor(Math.random() * chars.length))
+ }
+ setVerifyCode(code)
+ }
+
+ // 初始化 Turnstile
+ const initTurnstile = (containerId: 'login' | 'register') => {
+ const container = containerId === 'login' ? turnstileLoginRef.current : turnstileRegisterRef.current
+ const widgetKey = containerId === 'login' ? 'login' : 'register'
+
+ if (!container || !window.turnstile) return
+
+ // 清除已有的 widget
+ if (turnstileWidgetId.current[widgetKey]) {
+ try {
+ window.turnstile.remove(turnstileWidgetId.current[widgetKey]!)
+ } catch (e) {
+ // ignore
+ }
+ turnstileWidgetId.current[widgetKey] = null
+ }
+
+ try {
+ const widgetId = window.turnstile.render(container, {
+ sitekey: TURNSTILE_SITE_KEY,
+ theme: 'light',
+ callback: (token: string) => {
+ console.log(token)
+ },
+ 'expired-callback': () => {
+ console.log(`Turnstile token expired for ${containerId}`)
+ },
+ // @ts-ignore
+ 'error-callback': (err: any) => {
+ console.error(`Turnstile error for ${containerId}:`, err)
+ },
+ })
+ turnstileWidgetId.current[widgetKey] = widgetId
+ } catch (e) {
+ console.warn(`Turnstile render failed for ${containerId}:`, e)
+ }
+ }
+
+ // 当 Turnstile 准备好且容器存在时初始化
+ useEffect(() => {
+ if (turnstileReady && turnstileLoginRef.current) {
+ initTurnstile('login')
+ }
+ }, [turnstileReady, isLoginMode])
+
+ useEffect(() => {
+ if (turnstileReady && !isLoginMode && turnstileRegisterRef.current) {
+ initTurnstile('register')
+ }
+ }, [turnstileReady, isLoginMode])
+
+ // 获取 Turnstile token
+ const getTurnstileToken = (mode: 'login' | 'register'): string | null => {
+ const widgetId = turnstileWidgetId.current[mode]
+ if (window.turnstile && widgetId) {
+ try {
+ return window.turnstile.getResponse(widgetId)
+ } catch (e) {
+ return null
+ }
+ }
+ return null
+ }
+
+ // 重置 Turnstile
+ const resetTurnstile = (mode: 'login' | 'register') => {
+ const widgetId = turnstileWidgetId.current[mode]
+ if (window.turnstile && widgetId) {
+ try {
+ window.turnstile.reset(widgetId)
+ } catch (e) {
+ // ignore
+ }
+ }
+ }
+
+ const refreshVerification = (mode: 'login' | 'register') => {
+ generateVerifyCode()
+ const form = mode === 'login' ? loginForm : registerForm
+ form.setFieldsValue({ code: '' })
+ resetTurnstile(mode)
+ }
+
+ const showAuthError = (mode: 'login' | 'register', errorMessage: string) => {
+ message.error(errorMessage)
+ refreshVerification(mode)
+ }
+
+ const handleLogin = async (values: any) => {
+ const isCodeValid = values.code?.toUpperCase() === verifyCode.toUpperCase()
+
+ if (!isCodeValid) {
+ showAuthError('login', t('login.verifyCodeError'))
+ return
+ }
+
+ if (!isAgreed) {
+ showAuthError('login', t('login.agreeRequired'))
+ return
+ }
+
+ // 获取 Turnstile token
+ const turnstileToken = getTurnstileToken('login')
+
+ // 如果没有 token 且 Turnstile 已加载,说明用户未完成验证(仅生产环境检查)
+ if (!import.meta.env.DEV && turnstileReady && !turnstileToken) {
+ showAuthError('login', '请完成人机验证')
+ return
+ }
+
+ setLoading(true)
+ try {
+ const response = await fetch('/api/auth/login', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ username: values.username,
+ password: values.password,
+ ...(turnstileToken ? { 'cf-turnstile-response': turnstileToken } : {})
+ })
+ })
+
+ const data = await response.json()
+
+ if (data.success) {
+ // 保存登录状态和 token
+ setToken(data.token)
+ localStorage.setItem('isLoggedIn', 'true')
+ localStorage.setItem('username', data.user.username)
+ localStorage.setItem('userId', data.user.id.toString())
+ if (data.user.avatar) {
+ localStorage.setItem('avatar', data.user.avatar)
+ }
+
+ message.success(t('login.loginSuccess'))
+ if (!isMobileDevice()) {
+ setShowHeartBeat(true)
+ } else {
+ setShowHeartBeat(false)
+ navigate('/home')
+ }
+ } else {
+ showAuthError('login', data.message || t('login.loginFailed'))
+ }
+ } catch (error: any) {
+ console.error('Login error:', error)
+ showAuthError('login', error.message || t('login.loginFailed'))
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleRegister = async (values: any) => {
+ const isCodeValid = values.code?.toUpperCase() === verifyCode.toUpperCase()
+
+ if (!isCodeValid) {
+ showAuthError('register', t('login.verifyCodeError'))
+ return
+ }
+
+ if (!isAgreed) {
+ showAuthError('register', t('login.agreeRequired'))
+ return
+ }
+
+ if (values.password !== values.confirmPassword) {
+ showAuthError('register', t('login.confirmPasswordMismatch'))
+ return
+ }
+
+ // 获取 Turnstile token
+ const turnstileToken = getTurnstileToken('register')
+
+ // 如果没有 token 且 Turnstile 已加载,说明用户未完成验证(仅生产环境检查)
+ if (!import.meta.env.DEV && turnstileReady && !turnstileToken) {
+ showAuthError('register', '请完成人机验证')
+ return
+ }
+
+ setLoading(true)
+ try {
+ const response = await fetch('/api/auth/register', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ username: values.username,
+ password: values.password,
+ email: values.email,
+ ...(turnstileToken ? { 'cf-turnstile-response': turnstileToken } : {})
+ })
+ })
+
+ const data = await response.json()
+
+ if (data.success) {
+ message.success(t('login.registerSuccess'))
+ toggleMode()
+ } else {
+ showAuthError('register', data.message || t('login.registerFailed'))
+ }
+ } catch (error: any) {
+ console.error('Register error:', error)
+ showAuthError('register', error.message || t('login.registerFailed'))
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleHeartBeatComplete = () => {
+ navigate('/home')
+ }
+
+ const handleReset = () => {
+ loginForm.resetFields()
+ registerForm.resetFields()
+ generateVerifyCode()
+ // 重置 Turnstile
+ resetTurnstile('login')
+ resetTurnstile('register')
+ }
+
+ const toggleMode = () => {
+ setIsLoginMode(!isLoginMode)
+ loginForm.resetFields()
+ registerForm.resetFields()
+ generateVerifyCode()
+ // 切换模式后重置对应的 Turnstile
+ setTimeout(() => {
+ resetTurnstile('login')
+ resetTurnstile('register')
+ }, 100)
+ }
+
+ // Google OAuth
+ const googleLogin = useGoogleLogin({
+ onSuccess: async (tokenResponse) => {
+ console.log('Google Access Token:', tokenResponse)
+
+ // 后端用 access_token 验证身份并签发本站 JWT
+ try {
+ const response = await fetch('/api/auth/google', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ accessToken: tokenResponse.access_token })
+ })
+ const data = await response.json()
+
+ if (data.success) {
+ applyOAuthSession(data)
+ message.success(t('login.loginSuccess'))
+ setShowHeartBeat(true)
+ } else {
+ message.error(data.message || t('login.loginFailed'))
+ }
+ } catch (error: any) {
+ console.error('Google login error:', error)
+ message.error(error.message || t('login.loginFailed'))
+ }
+ },
+ onError: () => {
+ console.error('Google Login Failed')
+ message.error(t('login.loginFailed'))
+ },
+ })
+
+ // GitHub OAuth 处理
+ const handleGithubLogin = () => {
+ const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID
+ if (!clientId || clientId === 'your_github_client_id_here') {
+ message.error('GitHub Client ID 未配置,请先配置')
+ return
+ }
+
+ // 构建 GitHub OAuth 授权 URL,回调回登录页(/),由登录页用 code 换取本站 JWT
+ const redirectUri = window.location.origin + '/'
+ const githubAuthUrl = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=user:email`
+
+ // 跳转到 GitHub 授权页面
+ window.location.href = githubAuthUrl
+ }
+
+ return (
+ <>
+ {showHeartBeat ? (
+
+ ) : (
+
+ {bubbles.map((bubble, index) => (
+
+ ))}
+
+
+
+
+ ChattyPlay
+
+
+ {isLoginMode ? (
+
+ }
+ placeholder={t('login.accountPlaceholder')}
+ size="large"
+ autoComplete="off"
+ />
+
+
+
+ }
+ placeholder={t('login.passwordPlaceholder')}
+ size="large"
+ iconRender={(visible) => (
+ visible ? :
+ )}
+ />
+
+
+
+
+
+ }
+ placeholder={t('login.verifyCodePlaceholder')}
+ autoComplete="off"
+ size="large"
+ maxLength={4}
+ style={{ flex: 1 }}
+ />
+
+
+
+
+
+
+
+
+
+ setIsAgreed(e.target.checked)}
+ >
+ {t('login.agree')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+ }
+ placeholder={t('login.accountPlaceholder')}
+ size="large"
+ autoComplete="off"
+ />
+
+
+
+ }
+ placeholder={t('login.passwordPlaceholder')}
+ size="large"
+ iconRender={(visible) => (
+ visible ? :
+ )}
+ />
+
+
+ ({
+ validator(_, value) {
+ if (!value || getFieldValue('password') === value) {
+ return Promise.resolve()
+ }
+ return Promise.reject(new Error(t('login.confirmPasswordMismatch')))
+ },
+ }),
+ ]}
+ >
+ }
+ placeholder={t('login.confirmPasswordPlaceholder')}
+ size="large"
+ iconRender={(visible) => (
+ visible ? :
+ )}
+ />
+
+
+ {/* 注册时添加邮箱字段(如果需要) */}
+
+
+
+
+
+
+
+ }
+ placeholder={t('login.verifyCodePlaceholder')}
+ autoComplete="off"
+ size="large"
+ maxLength={4}
+ style={{ flex: 1 }}
+ />
+
+
+
+
+
+
+
+
+
+ setIsAgreed(e.target.checked)}
+ >
+ {t('login.agree')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ {isLoginMode ? t('login.switchToRegister') : t('login.switchToLogin')}
+
+
+ {/* OAuth 第三方登录 */}
+ {t('login.thirdPartyLoginTip')}
+
+
+ googleLogin()}>
+
+ Google
+
+
+
+ GitHub
+
+
+
+
+ )}
+ >
+ )
+}
+
+export default Login
diff --git a/src/pages/Markdown.tsx b/src/pages/Markdown.tsx
new file mode 100644
index 0000000..e56c7c5
--- /dev/null
+++ b/src/pages/Markdown.tsx
@@ -0,0 +1,677 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Button, Drawer, Dropdown, Input, Modal, Radio, Switch, Tooltip, message } from 'antd'
+import type { MenuProps } from 'antd'
+import {
+ BulbOutlined,
+ CloseOutlined,
+ DownloadOutlined,
+ EditOutlined,
+ EyeOutlined,
+ FileAddOutlined,
+ FolderOpenOutlined,
+ LinkOutlined,
+ PrinterOutlined,
+ RetweetOutlined,
+ UnorderedListOutlined,
+} from '@ant-design/icons'
+import MarkdownEditor from '@/components/markdown/MarkdownEditor'
+import MarkdownPreview from '@/components/markdown/MarkdownPreview'
+import TableOfContents from '@/components/markdown/TableOfContents'
+import { buildStandaloneHtml, renderMarkdown, type ThemeMode } from '@/utils/markdown'
+import { buildShareUrl, clearShareHash, readShareDocFromHash } from '@/utils/markdownShare'
+import 'katex/dist/katex.min.css'
+
+const DOC_KEY = 'markdown-doc'
+const THEME_KEY = 'markdown-theme'
+
+/** 默认欢迎文档:覆盖 Markdown 常用排版、代码高亮、KaTeX 公式与 Mermaid 图表 */
+const WELCOME_DOC = `# Markdown 编辑器
+
+左侧编写 Markdown,右侧**实时预览**;文档只保存在你的浏览器里,无需注册。
+
+## 支持的语法
+
+- **粗体**、*斜体*、~~删除线~~、\`行内代码\`
+- [超链接](https://markview.art)
+- 任务列表
+ - [x] 实时预览
+ - [x] 代码高亮
+ - [ ] 你的下一步
+
+> 引用块:把想法写下来,比记住它更可靠。
+
+## 代码高亮
+
+\`\`\`ts
+export function greet(name: string): string {
+ // 自动识别语言并高亮
+ return \`Hello, \${name}!\`
+}
+\`\`\`
+
+## 数学公式(KaTeX)
+
+行内公式:质能方程 $E = mc^2$,欧拉恒等式 $e^{i\\pi} + 1 = 0$。
+
+块级公式:
+
+$$
+\\int_{-\\infty}^{\\infty} e^{-x^2} \\, dx = \\sqrt{\\pi}
+$$
+
+## 表格
+
+| 功能 | 语法 | 状态 |
+| --- | --- | --- |
+| 公式 | \`$...$\` | ✅ |
+| 图表 | \`\`\`mermaid | ✅ |
+| 导出 | .md / .html / PDF | ✅ |
+
+## 流程图(Mermaid)
+
+\`\`\`mermaid
+flowchart LR
+ A[编写 Markdown] --> B{实时预览}
+ B -->|满意| C[导出 PDF]
+ B -->|继续改| A
+\`\`\`
+
+\`\`\`mermaid
+sequenceDiagram
+ participant U as 用户
+ participant E as 编辑器
+ participant P as 预览
+ U->>E: 输入 Markdown
+ E->>P: 实时渲染
+ P-->>U: 公式 / 图表 / 高亮
+\`\`\`
+
+## 目录与分享
+
+- 点工具栏的**目录**按钮,右侧会展开大纲;点标题即可跳转,滚动时自动高亮当前章节。
+- 点工具栏的**分享**按钮,整篇文档会被压缩进链接里,对方打开即可只读查看。
+
+---
+
+开始编辑这份文档吧,内容会自动保存。
+`
+
+const MarkdownPage: React.FC = () => {
+ // 打开分享链接时文档内容在 hash 里,进入只读查看模式
+ const [shareDoc, setShareDoc] = useState(() => readShareDocFromHash())
+
+ const [source, setSource] = useState(() => {
+ if (shareDoc !== null) return shareDoc
+ try {
+ return localStorage.getItem(DOC_KEY) ?? WELCOME_DOC
+ } catch {
+ return WELCOME_DOC
+ }
+ })
+ const [theme, setTheme] = useState(() => {
+ try {
+ const saved = localStorage.getItem(THEME_KEY)
+ if (saved === 'light' || saved === 'dark') return saved
+ } catch { /* ignore */ }
+ return 'light'
+ })
+ const [syncScroll, setSyncScroll] = useState(true)
+ const [isMobile, setIsMobile] = useState(window.innerWidth < 700)
+ const [mobileView, setMobileView] = useState<'editor' | 'preview'>('editor')
+
+ // 目录
+ const [showToc, setShowToc] = useState(false)
+ const [tocDrawerOpen, setTocDrawerOpen] = useState(false)
+ const [activeHeadingId, setActiveHeadingId] = useState(null)
+ const [scrollTarget, setScrollTarget] = useState<{ id: string; ts: number } | null>(null)
+
+ // 分享
+ const [shareOpen, setShareOpen] = useState(false)
+ const [shareUrl, setShareUrl] = useState('')
+
+ const isSharedView = shareDoc !== null
+
+ // 双向同步滚动:只把“主动滚动的一侧”的比例传给另一侧
+ const [previewRatio, setPreviewRatio] = useState(undefined)
+ const [editorRatio, setEditorRatio] = useState(undefined)
+ const lastRatioRef = useRef<{ editor: number; preview: number }>({ editor: 0, preview: 0 })
+
+ const fileInputRef = useRef(null)
+
+ // 一次渲染同时产出 HTML 与目录数据,两者同源,标题锚点天然一致
+ const { html, headings } = useMemo(() => renderMarkdown(source), [source])
+
+ // 窗口尺寸监听
+ useEffect(() => {
+ const onResize = () => setIsMobile(window.innerWidth < 700)
+ window.addEventListener('resize', onResize)
+ return () => window.removeEventListener('resize', onResize)
+ }, [])
+
+ // 自动保存(防抖)。查看分享文档时不写回本地,否则会覆盖自己的文档
+ useEffect(() => {
+ if (isSharedView) return
+ const timer = window.setTimeout(() => {
+ try {
+ localStorage.setItem(DOC_KEY, source)
+ } catch { /* 容量超限时忽略 */ }
+ }, 300)
+ return () => window.clearTimeout(timer)
+ }, [source, isSharedView])
+
+ // 主题持久化
+ useEffect(() => {
+ try {
+ localStorage.setItem(THEME_KEY, theme)
+ } catch { /* ignore */ }
+ }, [theme])
+
+ const handleEditorScroll = useCallback(
+ (ratio: number) => {
+ if (!syncScroll) return
+ if (Math.abs(ratio - lastRatioRef.current.preview) < 0.005) return
+ lastRatioRef.current.preview = ratio
+ setPreviewRatio(ratio)
+ },
+ [syncScroll]
+ )
+
+ const handlePreviewScroll = useCallback(
+ (ratio: number) => {
+ if (!syncScroll) return
+ if (Math.abs(ratio - lastRatioRef.current.editor) < 0.005) return
+ lastRatioRef.current.editor = ratio
+ setEditorRatio(ratio)
+ },
+ [syncScroll]
+ )
+
+ const stats = useMemo(() => {
+ const chars = source.length
+ const lines = source ? source.split('\n').length : 0
+ const words = source.trim() ? source.trim().split(/\s+/).length : 0
+ return { chars, lines, words }
+ }, [source])
+
+ // 文档标题:首个一级标题,否则 'markdown-document'
+ const docTitle = useMemo(() => {
+ const match = /^\s*#\s+(.+)$/m.exec(source)
+ return match ? match[1].trim() : 'markdown-document'
+ }, [source])
+
+ const downloadBlob = (blob: Blob, filename: string) => {
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = filename
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+ }
+
+ /** 导出 .md 源文件 */
+ const exportMarkdown = useCallback(() => {
+ const safeName = docTitle.replace(/[\\/:*?"<>|]/g, '_') || 'markdown-document'
+ downloadBlob(new Blob([source], { type: 'text/markdown;charset=utf-8' }), `${safeName}.md`)
+ message.success('已导出 Markdown 文件')
+ }, [source, docTitle])
+
+ /**
+ * 导出 .html:直接取预览容器的渲染结果,
+ * 这样 KaTeX 公式与已渲染的 Mermaid SVG 都能一起带走
+ */
+ const exportHtml = useCallback(() => {
+ const rendered = document.querySelector('.md-preview')?.innerHTML
+ if (!rendered) {
+ message.warning('预览尚未就绪,请稍后重试')
+ return
+ }
+ const html = buildStandaloneHtml(docTitle, rendered, theme)
+ downloadBlob(new Blob([html], { type: 'text/html;charset=utf-8' }), `${docTitle}.html`)
+ message.success('已导出 HTML 文件')
+ }, [docTitle, theme])
+
+ /** 导出 PDF:走浏览器打印,打印样式只保留预览区域 */
+ const exportPdf = useCallback(() => {
+ message.info('在打印窗口中选择「另存为 PDF」即可导出')
+ const root = document.documentElement
+ // data-md-print 让打印样式只作用于本页的预览区域
+ root.setAttribute('data-md-print', '')
+
+ const cleanup = () => {
+ root.removeAttribute('data-md-print')
+ window.removeEventListener('afterprint', cleanup)
+ }
+ window.addEventListener('afterprint', cleanup)
+
+ window.setTimeout(() => {
+ try {
+ window.print()
+ } finally {
+ // 部分浏览器不触发 afterprint,这里兜底清理
+ window.setTimeout(cleanup, 1000)
+ }
+ }, 120)
+ }, [])
+
+ /** 导入本地 .md / .markdown / .txt 文件 */
+ const handleImportFile = useCallback((file: File) => {
+ const reader = new FileReader()
+ reader.onload = () => {
+ setSource(String(reader.result ?? ''))
+ message.success(`已导入 ${file.name}`)
+ }
+ reader.onerror = () => message.error('文件读取失败')
+ reader.readAsText(file, 'utf-8')
+ }, [])
+
+ const handleClear = useCallback(() => {
+ Modal.confirm({
+ title: '清空当前文档?',
+ content: '清空后无法恢复,已保存的内容将被移除。',
+ okText: '清空',
+ okButtonProps: { danger: true },
+ cancelText: '取消',
+ onOk: () => {
+ setSource(WELCOME_DOC)
+ message.success('已重置为示例文档')
+ },
+ })
+ }, [])
+
+ // ---------- 目录 ----------
+
+ /** 点击目录项:跳转到预览中的对应标题 */
+ const handleSelectHeading = useCallback(
+ (id: string) => {
+ setScrollTarget({ id, ts: Date.now() })
+ if (isMobile) {
+ // 移动端编辑与预览是互斥视图,先切到预览再定位
+ setMobileView('preview')
+ setTocDrawerOpen(false)
+ }
+ },
+ [isMobile]
+ )
+
+ const handleToggleToc = useCallback(() => {
+ if (isMobile) {
+ setTocDrawerOpen((open) => !open)
+ } else {
+ setShowToc((open) => !open)
+ }
+ }, [isMobile])
+
+ // ---------- 分享 ----------
+
+ /** 把当前文档压缩进链接 */
+ const handleShare = useCallback(() => {
+ setShareUrl(buildShareUrl(source))
+ setShareOpen(true)
+ }, [source])
+
+ const handleCopyShareUrl = useCallback(async () => {
+ try {
+ await navigator.clipboard.writeText(shareUrl)
+ message.success('链接已复制到剪贴板')
+ } catch {
+ // 非安全上下文或没有剪贴板权限时的兜底方案
+ try {
+ const textarea = document.createElement('textarea')
+ textarea.value = shareUrl
+ textarea.style.position = 'fixed'
+ textarea.style.opacity = '0'
+ document.body.appendChild(textarea)
+ textarea.select()
+ document.execCommand('copy')
+ document.body.removeChild(textarea)
+ message.success('链接已复制到剪贴板')
+ } catch {
+ message.error('复制失败,请手动选中链接复制')
+ }
+ }
+ }, [shareUrl])
+
+ /** 把分享的文档存为自己的文档(同时解除只读) */
+ const handleSaveSharedToMine = useCallback(() => {
+ try {
+ localStorage.setItem(DOC_KEY, source)
+ } catch { /* 容量超限时忽略 */ }
+ clearShareHash()
+ setShareDoc(null)
+ message.success('已保存到我的文档,现在可以编辑了')
+ }, [source])
+
+ /** 退出分享查看,回到自己的文档 */
+ const handleExitSharedView = useCallback(() => {
+ clearShareHash()
+ setShareDoc(null)
+ try {
+ setSource(localStorage.getItem(DOC_KEY) ?? WELCOME_DOC)
+ } catch {
+ setSource(WELCOME_DOC)
+ }
+ message.success('已返回我的文档')
+ }, [])
+
+ const exportMenu: MenuProps['items'] = [
+ { key: 'md', icon: , label: '导出 Markdown (.md)' },
+ { key: 'html', icon: , label: '导出 HTML (.html)' },
+ { key: 'pdf', icon: , label: '导出 PDF(打印)' },
+ ]
+
+ const toolbarBg = theme === 'dark' ? '#252529' : '#fafafa'
+ const toolbarBorder = theme === 'dark' ? '#333338' : '#f0f0f0'
+ const textColor = theme === 'dark' ? '#e6e6e6' : '#333'
+ const subtleColor = theme === 'dark' ? '#9ba1a6' : '#999'
+ const editorBg = theme === 'dark' ? '#1e1e21' : '#fff'
+
+ const editorNode = (
+
+ )
+
+ const previewNode = (
+
+ )
+
+ const tocNode = (
+
+ )
+
+ return (
+
+ {/* 顶部工具栏 */}
+
+
+ Markdown 编辑器
+ {!isMobile && (
+
+ {stats.lines} 行 · {stats.words} 词 · {stats.chars} 字符 · 自动保存中
+
+ )}
+
+
+
+ {!isMobile && (
+
+
+
+ 同步滚动
+
+
+
+ )}
+
+
+ }
+ type={!isMobile && showToc ? 'primary' : 'default'}
+ onClick={handleToggleToc}
+ />
+
+
+
+ }
+ onClick={handleShare}
+ />
+
+
+
+ }
+ onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
+ />
+
+
+ {/* 查看分享文档时不提供导入/重置,避免覆盖来源不明的内容 */}
+ {!isMobile && !isSharedView && (
+ <>
+
+ }
+ onClick={() => fileInputRef.current?.click()}
+ />
+
+
+
+ } onClick={handleClear} />
+
+ >
+ )}
+
+ {
+ if (key === 'md') exportMarkdown()
+ else if (key === 'html') exportHtml()
+ else exportPdf()
+ },
+ }}
+ placement="bottomRight"
+ >
+ }
+ style={{
+ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
+ border: 'none',
+ }}
+ >
+ 导出
+
+
+
+
+
+ {/* 隐藏的文件选择框 */}
+ {
+ const file = e.target.files?.[0]
+ if (file) handleImportFile(file)
+ e.target.value = ''
+ }}
+ />
+
+ {/* 分享查看提示条 */}
+ {isSharedView && (
+
+
+ 你正在查看别人分享的文档(只读)
+
+
+
+
+
+
+ )}
+
+ {/* 主体:左编辑 右预览(展开目录时右侧再挂一栏) */}
+
+ {!isMobile && (
+ <>
+
+ {editorNode}
+
+
+ {previewNode}
+
+ {showToc && (
+
+ )}
+ >
+ )}
+
+ {isMobile && (
+
+ {mobileView === 'editor' ? editorNode : previewNode}
+
+ )}
+
+
+ {/* 移动端底部切换 */}
+ {isMobile && (
+
+ setMobileView(e.target.value)}
+ optionType="button"
+ buttonStyle="solid"
+ size="small"
+ style={{ display: 'flex', width: '100%' }}
+ >
+
+ 编辑
+
+
+ 预览
+
+
+
+ )}
+
+ {/* 分享链接弹窗 */}
+ setShareOpen(false)}
+ footer={[
+ ,
+ ,
+ ]}
+ >
+
+ 整篇文档已压缩进链接,对方打开即可只读查看;内容不会上传到服务器。
+
+ e.target.select()}
+ />
+ 8000 ? '#e11d48' : '#999',
+ }}
+ >
+ 链接长度 {shareUrl.length} 字符
+ {shareUrl.length > 8000
+ ? ',偏长,部分聊天工具可能会截断,建议改用「导出 HTML」'
+ : ';接收者需要登录后才能打开'}
+
+
+
+ {/* 移动端目录抽屉 */}
+ setTocDrawerOpen(false)}
+ styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column' } }}
+ >
+
+ {tocNode}
+
+
+
+ )
+}
+
+export default MarkdownPage
diff --git a/src/pages/Markmap.tsx b/src/pages/Markmap.tsx
new file mode 100644
index 0000000..91a22b2
--- /dev/null
+++ b/src/pages/Markmap.tsx
@@ -0,0 +1,1051 @@
+import React, { useState, useEffect, useRef } from 'react'
+import { message } from 'antd'
+import { useTranslation } from 'react-i18next'
+import EditorPanel from '@/components/markmap/EditorPanel'
+import MindmapPanel from '@/components/markmap/MindmapPanel'
+import MobileMenu from '@/components/markmap/MobileMenu'
+import MobileTabBar from '@/components/markmap/MobileTabBar'
+import PromptModal from '@/components/markmap/PromptModal'
+import EditNodeModal from '@/components/markmap/EditNodeModal'
+import InfoModal from '@/components/markmap/InfoModal'
+import LandscapeMode from '@/components/markmap/LandscapeMode'
+import {
+ waitForLibraries,
+ getNodeColor,
+ createExportableSvg,
+ downloadBlob,
+ generateExportFileName,
+ collectNodeLines,
+ removeContextMenu,
+ createContextMenu,
+ LAST_SUCCESSFUL_MODEL_KEY,
+ type AiResult,
+ type NodeContext
+} from '@/utils/markmap'
+
+const Markmap: React.FC = () => {
+ const { t } = useTranslation()
+ const [mm, setMm] = useState(null)
+ const [transformer, setTransformer] = useState(null)
+ const [currentMarkdown, setCurrentMarkdown] = useState('')
+ const [aiResults, setAiResults] = useState([])
+ const [activeResultIndex, setActiveResultIndex] = useState(-1)
+ const [currentViewMode, setCurrentViewMode] = useState<'input' | 'original' | 'markdown'>('input')
+ const [aiPromptTemplate, setAiPromptTemplate] = useState('')
+ const [editingNodeContext, setEditingNodeContext] = useState(null)
+ const [isLoading, setIsLoading] = useState(false)
+ const [timerSeconds, setTimerSeconds] = useState(0)
+ const [statusMessage, setStatusMessage] = useState('')
+ const [apiUrl, setApiUrl] = useState('')
+ const [apiKey, setApiKey] = useState('')
+ const [selectedModel, setSelectedModel] = useState