diff --git a/web/frontend/src/app/settings/page.tsx b/web/frontend/src/app/settings/page.tsx index f5aefbb..6006adb 100644 --- a/web/frontend/src/app/settings/page.tsx +++ b/web/frontend/src/app/settings/page.tsx @@ -1,80 +1,116 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { SiteLayout } from '@/components/site/SiteLayout'; import { useAuth } from '@/lib/auth'; import { useLocalLLMKeys } from '@/hooks/useLocalLLMKeys'; - -const PROVIDERS = [ - { key: 'openai', label: 'OpenAI', url: 'https://api.openai.com/v1', models: 'gpt-4o / gpt-4o-mini' }, - { key: 'deepseek', label: 'DeepSeek', url: 'https://api.deepseek.com/v1', models: 'deepseek-chat / deepseek-reasoner' }, - { key: 'openrouter', label: 'OpenRouter', url: 'https://openrouter.ai/api/v1', models: '多模型聚合' }, - { key: 'custom', label: '自定义兼容端点', url: '', models: '兼容 OpenAI 协议' }, -]; +import { CUSTOM_MODEL_TYPES, customModelConfig, CustomModelType } from '@/lib/customModelConfig'; export default function SettingsPage() { const { user } = useAuth(); const { hasLocalKey, saveLocalKey, clearLocalKey } = useLocalLLMKeys(); - const [drafts, setDrafts] = useState>({}); - const [saved, setSaved] = useState(null); + const [keyDrafts, setKeyDrafts] = useState>({}); + const [urlDrafts, setUrlDrafts] = useState>({}); + const [savedType, setSavedType] = useState(null); + + // 登录用户变化 / 首挂载时,用已保存的 Base URL(或默认值)填充表单 + useEffect(() => { + if (!user) return; + setUrlDrafts((prev) => { + const next = { ...prev }; + for (const t of CUSTOM_MODEL_TYPES) { + if (next[t.key] == null) { + next[t.key] = customModelConfig.getBaseUrl(user.id, t.key) || t.defaultBaseUrl; + } + } + return next; + }); + }, [user]); - const setDraft = (k: string, v: string) => setDrafts((p) => ({ ...p, [k]: v })); + const setKeyDraft = (k: string, v: string) => setKeyDrafts((p) => ({ ...p, [k]: v })); + const setUrlDraft = (k: string, v: string) => setUrlDrafts((p) => ({ ...p, [k]: v })); + + const save = (type: CustomModelType) => { + if (!user) return; + const keyVal = keyDrafts[type]?.trim(); + if (keyVal) saveLocalKey(type, keyVal); + customModelConfig.saveBaseUrl(user.id, type, urlDrafts[type] ?? ''); + setKeyDrafts((p) => ({ ...p, [type]: '' })); + setSavedType(type); + setTimeout(() => setSavedType(null), 1800); + }; - const save = (k: string) => { - const val = drafts[k]?.trim(); - if (!val) return; - saveLocalKey(k, val); - setDrafts((p) => ({ ...p, [k]: '' })); - setSaved(k); - setTimeout(() => setSaved(null), 1800); + const clear = (type: CustomModelType) => { + if (!user) return; + clearLocalKey(type); + customModelConfig.clearBaseUrl(user.id, type); + setUrlDrafts((p) => ({ ...p, [type]: CUSTOM_MODEL_TYPES.find((t) => t.key === type)!.defaultBaseUrl })); }; return (

自定义模型设置

- 配置自定义 LLM 接口与密钥。这些信息仅保存在你的浏览器本地,服务端不存储、不传输持久化。 + 配置自定义 LLM 接口与密钥,只按接口类型选择,不绑定具体提供商。这些信息仅保存在你的浏览器本地,服务端不存储、不传输持久化。

- 本地 Key 仅写入浏览器 localStorage(按账户隔离),不会随分析请求持久化到服务端,也不会出现在公开报告中。 + 配置仅写入浏览器 localStorage(按账户隔离),不会随分析请求持久化到服务端,也不会出现在公开报告中。 清除浏览器数据将一并清除。配置后,分析将优先使用你的自定义模型。
{!user && (

- 本地 Key 按账户隔离保存,登录后才会启用。 + 配置按账户隔离保存,登录后才会启用。

)}
- {PROVIDERS.map((p) => { - const has = user ? hasLocalKey(p.key) : false; + {CUSTOM_MODEL_TYPES.map((t) => { + const has = user ? hasLocalKey(t.key) : false; + const keyDraft = keyDrafts[t.key] ?? ''; + const urlDraft = urlDrafts[t.key] ?? ''; + const urlDirty = user ? urlDraft.trim() !== (customModelConfig.getBaseUrl(user.id, t.key) || t.defaultBaseUrl) : false; return ( -
-
-
-

{p.label}

-

{p.url || '需填写 Base URL'} · {p.models}

+
+
+
+

{t.label}

+

{t.desc}

- {has && 已配置} + {has && 已配置}
-
+
+ setDraft(p.key, e.target.value)} - placeholder={has ? '••••••••(输入新值可替换)' : '粘贴 API Key'} - className="h-10 flex-1 rounded-lg border border-dark-border bg-dark-input px-3 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent-primary focus:outline-none" + value={keyDraft} + onChange={(e) => setKeyDraft(t.key, e.target.value)} + placeholder={has ? '••••••••(输入新值可替换)' : '粘贴该服务的 API Key'} + className="h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent-primary focus:outline-none" /> -
+
+ {has && ( - )} @@ -86,4 +122,3 @@ export default function SettingsPage() { ); } - diff --git a/web/frontend/src/components/auth/AuthLayout.tsx b/web/frontend/src/components/auth/AuthLayout.tsx index c0274e0..99b6887 100644 --- a/web/frontend/src/components/auth/AuthLayout.tsx +++ b/web/frontend/src/components/auth/AuthLayout.tsx @@ -21,7 +21,7 @@ export function AuthLayout({ toggleLinkText }: AuthLayoutProps) { return ( -
+
{/* Background decoration */}
diff --git a/web/frontend/src/components/auth/Turnstile.tsx b/web/frontend/src/components/auth/Turnstile.tsx index 2c4b59c..3259115 100644 --- a/web/frontend/src/components/auth/Turnstile.tsx +++ b/web/frontend/src/components/auth/Turnstile.tsx @@ -123,7 +123,7 @@ export const Turnstile = forwardRef(function Turns }, []); return ( -
+
{loadError && (

diff --git a/web/frontend/src/components/site/SearchBar.tsx b/web/frontend/src/components/site/SearchBar.tsx index 34dc153..9d5394d 100644 --- a/web/frontend/src/components/site/SearchBar.tsx +++ b/web/frontend/src/components/site/SearchBar.tsx @@ -32,7 +32,7 @@ export function SearchBar({ size = 'lg' }: { size?: 'lg' | 'md' }) { type="text" value={value} onChange={(e) => setValue(e.target.value)} - placeholder="输入股票代码、公司名称,或一句研究指令,如「把 minimax 加入港股通对后市的影响」" + placeholder="输入股票代码、公司名称,或一句研究指令,如「宁德时代海外扩张对未来 12 个月利润的影响」" className={`w-full rounded-xl border border-dark-border bg-dark-input ${height} ${textSize} pl-11 pr-28 text-text-primary placeholder:text-text-tertiary focus:border-accent-primary focus:outline-none focus:ring-1 focus:ring-accent-primary/40`} autoFocus={size === 'lg'} /> diff --git a/web/frontend/src/components/site/SiteFooter.tsx b/web/frontend/src/components/site/SiteFooter.tsx index dbae53c..0282df8 100644 --- a/web/frontend/src/components/site/SiteFooter.tsx +++ b/web/frontend/src/components/site/SiteFooter.tsx @@ -1,6 +1,11 @@ +'use client'; + import Link from 'next/link'; +import { useAuth } from '@/lib/auth'; export function SiteFooter() { + const { user } = useAuth(); + return (

@@ -14,7 +19,9 @@ export function SiteFooter() { 研究 公开榜单 订阅 - 自定义模型 + {/* 未登录时设置入口在顶部导航(登录按钮左侧的齿轮图标); + 登录后顶部不再显示,只保留这里 —— 任何状态全站只有一个入口 */} + {user && 自定义模型}

@@ -25,4 +32,3 @@ export function SiteFooter() {

); } - diff --git a/web/frontend/src/components/site/SiteHeader.tsx b/web/frontend/src/components/site/SiteHeader.tsx index f32393b..be74f24 100644 --- a/web/frontend/src/components/site/SiteHeader.tsx +++ b/web/frontend/src/components/site/SiteHeader.tsx @@ -9,7 +9,6 @@ const NAV = [ { href: '/', label: '研究' }, { href: '/leaderboard', label: '公开榜单' }, { href: '/subscription', label: '订阅' }, - { href: '/settings', label: '自定义模型' }, ]; export function SiteHeader() { @@ -67,9 +66,19 @@ export function SiteHeader() {
) : ( - - 登录 - + <> + + + + + 登录 + + )}