From f9a632ad549391bc9c306a3f7355a3efc23dd119 Mon Sep 17 00:00:00 2001 From: shy3130 Date: Sun, 12 Jul 2026 11:04:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E4=BF=A1=E5=8F=B7=E5=AD=97=E6=AE=B5=E9=80=89=E6=8B=A9=E6=94=B9?= =?UTF-8?q?=E5=B1=85=E4=B8=AD=E5=AF=B9=E8=AF=9D=E6=A1=86=20+=20AI=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=96=B0=E5=A2=9E=E8=87=AA=E5=AE=9A=E4=B9=89=E9=A2=84?= =?UTF-8?q?=E8=AE=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CustomSignalDialog: FieldPicker 从浮动小面板改为居中对话框(Portal) 条件行 flex-wrap 防截断; 字段按钮弹性宽度 - AI.tsx: 新增"自定义"预设(默认选中, 不自动填充); 新用户未配置AI时字段留空 --- .../components/signals/CustomSignalDialog.tsx | 176 ++++++++---------- frontend/src/pages/settings/AI.tsx | 35 +++- 2 files changed, 99 insertions(+), 112 deletions(-) diff --git a/frontend/src/components/signals/CustomSignalDialog.tsx b/frontend/src/components/signals/CustomSignalDialog.tsx index ea764e4..0cc420d 100644 --- a/frontend/src/components/signals/CustomSignalDialog.tsx +++ b/frontend/src/components/signals/CustomSignalDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { createPortal } from 'react-dom' import { AnimatePresence, motion } from 'framer-motion' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' @@ -128,7 +128,7 @@ export function CustomSignalDialog({ open, signal, defaultKind = 'exit', onClose
{draft.conditions.map((c, i) => ( -
+
{i === 0 ? '当' : '且'} {/* 左操作数: 前N日 + 字段(弹出选择) */} @@ -174,11 +174,7 @@ export function CustomSignalDialog({ open, signal, defaultKind = 'exit', onClose ) } -// ── 字段选择器: 搜索 + 分组弹出 (Portal 到 body, 避免被弹窗 overflow 裁切) ── - -const FIELD_POP_WIDTH = 240 -const FIELD_POP_MAX_HEIGHT = 360 -const FIELD_POP_GAP = 4 +// ── 字段选择器: 搜索 + 分组居中对话框 ─────────────────── function FieldPicker({ value, fields, groups, onChange }: { value: string @@ -188,9 +184,6 @@ function FieldPicker({ value, fields, groups, onChange }: { }) { const [open, setOpen] = useState(false) const [query, setQuery] = useState('') - const [pos, setPos] = useState<{ top: number; left: number; dropUp: boolean } | null>(null) - const btnRef = useRef(null) - const popRef = useRef(null) const selectedLabel = fields.find(f => f.key === value)?.label ?? value const filteredGroups = useMemo(() => { @@ -207,110 +200,89 @@ function FieldPicker({ value, fields, groups, onChange }: { return fields.filter(f => f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q)) }, [fields, query, filteredGroups]) - // 打开: 计算按钮视口坐标 + 智能方向翻转 + 水平裁剪 - const handleOpen = () => { - if (!btnRef.current) { setOpen(true); return } - const r = btnRef.current.getBoundingClientRect() - const spaceBelow = window.innerHeight - r.bottom - const dropUp = spaceBelow < FIELD_POP_MAX_HEIGHT + FIELD_POP_GAP && r.top > FIELD_POP_MAX_HEIGHT + FIELD_POP_GAP - const top = dropUp ? Math.max(8, r.top - FIELD_POP_MAX_HEIGHT - FIELD_POP_GAP) : r.bottom + FIELD_POP_GAP - const left = Math.max(8, Math.min(r.left, window.innerWidth - FIELD_POP_WIDTH - 8)) - setPos({ top, left, dropUp }) - setQuery('') - setOpen(true) - } - - // 点击外部 / 滚动关闭 - useEffect(() => { - if (!open) return - const handler = (e: MouseEvent) => { - const t = e.target as Node - if (btnRef.current?.contains(t)) return - if (popRef.current?.contains(t)) return - setOpen(false) - } - const close = () => setOpen(false) - document.addEventListener('mousedown', handler) - window.addEventListener('scroll', close, true) - window.addEventListener('resize', close) - return () => { - document.removeEventListener('mousedown', handler) - window.removeEventListener('scroll', close, true) - window.removeEventListener('resize', close) - } - }, [open]) - return ( <> {createPortal( - {open && pos && ( + {open && ( setOpen(false)} > - {/* 搜索框 */} -
-
- - setQuery(e.target.value)} - placeholder="搜索字段…" - className="flex-1 bg-transparent text-[11px] text-foreground focus:outline-none" - /> - {query && } -
-
- {/* 分组列表 */} -
- {filteredGroups ? ( - filteredGroups.length > 0 ? filteredGroups.map(g => ( -
-
{g.label}
- {g.fields.map(f => ( - - ))} -
- )) : ( -
无匹配字段
- ) - ) : ( - filteredFields.map(f => ( - - )) - )} -
+
+
+ + setQuery(e.target.value)} + placeholder="搜索字段…" + className="flex-1 bg-transparent text-xs text-foreground focus:outline-none" + /> + {query && } +
+
+ {/* 分组列表 */} +
+ {filteredGroups ? ( + filteredGroups.length > 0 ? filteredGroups.map(g => ( +
+
{g.label}
+ {g.fields.map(f => ( + + ))} +
+ )) : ( +
无匹配字段
+ ) + ) : ( + filteredFields.map(f => ( + + )) + )} +
+ )} , diff --git a/frontend/src/pages/settings/AI.tsx b/frontend/src/pages/settings/AI.tsx index a674ef9..3048355 100644 --- a/frontend/src/pages/settings/AI.tsx +++ b/frontend/src/pages/settings/AI.tsx @@ -24,7 +24,8 @@ const CODEX_MODEL_OPTIONS = [ { label: 'gpt-5', value: 'gpt-5', hint: '通用模型' }, ] -const PRESETS: { label: string; provider?: string; url: string; model: string; codexCommand?: string; website: string; websiteLabel: string; description: string; partner?: boolean; promo?: string }[] = [ +const PRESETS: { label: string; provider?: string; url: string; model: string; codexCommand?: string; website: string; websiteLabel: string; description: string; partner?: boolean; promo?: string; custom?: boolean }[] = [ + { label: '自定义', url: '', model: '', website: '', websiteLabel: '', description: '不自动填充任何配置,完全手动填写 API 地址、模型和密钥。', custom: true }, { label: 'DeepSeek', url: 'https://api.deepseek.com', model: 'deepseek-v4-pro', website: 'https://www.deepseek.com/', websiteLabel: 'deepseek.com', description: 'DeepSeek 官方 OpenAI 兼容接口。' }, { label: '通义千问', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-3.6plus', website: 'https://tongyi.aliyun.com/', websiteLabel: 'tongyi.aliyun.com', description: '阿里云 DashScope 兼容模式接口。' }, { label: '智谱 GLM', url: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-5.2', website: 'https://open.bigmodel.cn/', websiteLabel: 'open.bigmodel.cn', description: '智谱 AI 官方 OpenAI 兼容接口。' }, @@ -55,16 +56,20 @@ export function SettingsAIPanel() { const isCodexProvider = provider === CODEX_PROVIDER const savedCodexProvider = s?.ai_provider === CODEX_PROVIDER const configured = s?.ai_configured ?? (savedCodexProvider ? !!(s?.ai_codex_command ?? CODEX_COMMAND) : s?.has_ai_key) - const selectedPreset = PRESETS.find(p => (p.provider ?? OPENAI_PROVIDER) === provider && (isCodexProvider ? p.codexCommand === codexCommand : p.url === baseUrl)) + // 选中的预设: 精确匹配 provider+url/codexCommand; 匹配不上时默认"自定义" + const matchedPreset = PRESETS.find(p => (p.provider ?? OPENAI_PROVIDER) === provider && (isCodexProvider ? p.codexCommand === codexCommand : p.url === baseUrl)) + const selectedPreset = matchedPreset ?? PRESETS.find(p => p.custom) const codexModelSelectValue = codexCustomModel ? CUSTOM_CODEX_MODEL : model const canSave = isCodexProvider ? true : !!baseUrl.trim() && !!model.trim() useEffect(() => { if (!s) return + // 未配置过 AI (无 api_key): 字段留空, 默认选中"自定义"预设, 不预填充后端默认值 + const unconfigured = !s.has_ai_key && !s.ai_configured setProvider(s.ai_provider ?? OPENAI_PROVIDER) - setBaseUrl(s.ai_base_url ?? '') - setModel(s.ai_model ?? '') - setCodexCustomModel(!!s.ai_model && !CODEX_MODEL_OPTIONS.some(o => o.value === s.ai_model)) + setBaseUrl(unconfigured ? '' : (s.ai_base_url ?? '')) + setModel(unconfigured ? '' : (s.ai_model ?? '')) + setCodexCustomModel(!unconfigured && !!s.ai_model && !CODEX_MODEL_OPTIONS.some(o => o.value === s.ai_model)) setCodexCommand(s.ai_codex_command ?? CODEX_COMMAND) const ua = s.ai_user_agent ?? '' setCustomUa(!!ua) @@ -139,6 +144,14 @@ export function SettingsAIPanel() { } const handlePreset = (p: typeof PRESETS[number]) => { + if (p.custom) { + // 自定义: 清空所有自动填充字段, 由用户完全手动填写 + setProvider(OPENAI_PROVIDER) + setBaseUrl('') + setModel('') + setCodexCustomModel(false) + return + } setProvider(p.provider ?? OPENAI_PROVIDER) setBaseUrl(p.url) setModel(p.model) @@ -213,11 +226,13 @@ export function SettingsAIPanel() { {selectedPreset.description} {selectedPreset.promo && {selectedPreset.promo}}
- - {selectedPreset.websiteLabel} - - + {selectedPreset.website && ( + + {selectedPreset.websiteLabel} + + + )} )}