feat(ui): 自定义信号字段选择改居中对话框 + AI配置新增自定义预设

- CustomSignalDialog: FieldPicker 从浮动小面板改为居中对话框(Portal)
  条件行 flex-wrap 防截断; 字段按钮弹性宽度
- AI.tsx: 新增"自定义"预设(默认选中, 不自动填充); 新用户未配置AI时字段留空
This commit is contained in:
shy3130
2026-07-12 11:04:49 +08:00
parent 20ef070099
commit f9a632ad54
2 changed files with 99 additions and 112 deletions
@@ -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
</div>
<div className="space-y-2 rounded-card border border-border/70 bg-base/50 p-3">
{draft.conditions.map((c, i) => (
<div key={i} className="flex items-center gap-1.5">
<div key={i} className="flex flex-wrap items-center gap-1.5">
<span className="text-[10px] text-muted/60 w-5 text-right shrink-0">{i === 0 ? '当' : '且'}</span>
{/* 左操作数: 前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<HTMLButtonElement>(null)
const popRef = useRef<HTMLDivElement>(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 (
<>
<button
ref={btnRef}
type="button"
onClick={handleOpen}
className="w-28 h-7 px-1.5 rounded bg-base border border-border text-[11px] text-foreground text-left hover:border-accent/40 transition-colors shrink-0 cursor-pointer truncate"
onClick={() => { setQuery(''); setOpen(true) }}
className="min-w-[80px] max-w-[180px] h-7 px-1.5 rounded bg-base border border-border text-[11px] text-foreground text-left hover:border-accent/40 transition-colors cursor-pointer truncate"
>
{selectedLabel}
</button>
{createPortal(
<AnimatePresence>
{open && pos && (
{open && (
<motion.div
ref={popRef}
initial={{ opacity: 0, y: pos.dropUp ? 4 : -4, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: pos.dropUp ? 4 : -4, scale: 0.97 }}
transition={{ duration: 0.13, ease: [0.16, 1, 0.3, 1] }}
style={{ position: 'fixed', top: pos.top, left: pos.left }}
className="z-[9999] bg-surface border border-border/60 rounded-xl shadow-[0_8px_30px_rgba(0,0,0,0.4)] flex flex-col overflow-hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
onClick={() => setOpen(false)}
>
{/* 搜索框 */}
<div className="p-2 border-b border-border/50">
<div className="flex items-center gap-2 px-2 h-7 rounded-btn bg-base border border-border">
<Search className="h-3 w-3 text-muted shrink-0" />
<input
autoFocus
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="搜索字段…"
className="flex-1 bg-transparent text-[11px] text-foreground focus:outline-none"
/>
{query && <button onClick={() => setQuery('')} className="text-muted hover:text-foreground"><X className="h-3 w-3" /></button>}
</div>
</div>
{/* 分组列表 */}
<div className="overflow-y-auto p-1.5" style={{ maxHeight: FIELD_POP_MAX_HEIGHT - 44 }}>
{filteredGroups ? (
filteredGroups.length > 0 ? filteredGroups.map(g => (
<div key={g.key} className="mb-0.5">
<div className="px-2 py-0.5 text-[10px] text-muted/60 font-medium">{g.label}</div>
{g.fields.map(f => (
<button
key={f.key}
onClick={() => { onChange(f.key); setOpen(false) }}
className={`w-full text-left px-2 py-1 rounded text-[11px] transition-colors ${
f.key === value ? 'bg-accent/10 text-accent' : 'text-foreground/80 hover:bg-elevated'
}`}
>
{f.label}
</button>
))}
</div>
)) : (
<div className="px-3 py-5 text-center text-[11px] text-muted"></div>
)
) : (
filteredFields.map(f => (
<button
key={f.key}
onClick={() => { onChange(f.key); setOpen(false) }}
className={`w-full text-left px-2 py-1 rounded text-[11px] transition-colors ${
f.key === value ? 'bg-accent/10 text-accent' : 'text-foreground/80 hover:bg-elevated'
}`}
>
{f.label}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
className="w-full max-w-sm bg-surface border border-border/50 rounded-2xl shadow-2xl flex flex-col overflow-hidden max-h-[70vh]"
onClick={e => e.stopPropagation()}
>
{/* 标题 + 搜索 */}
<div className="p-3 border-b border-border/50 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-foreground"></span>
<button onClick={() => setOpen(false)} className="rounded p-1 text-muted hover:bg-elevated hover:text-foreground transition-colors">
<X className="h-3.5 w-3.5" />
</button>
))
)}
</div>
</div>
<div className="flex items-center gap-2 px-2.5 h-8 rounded-btn bg-base border border-border">
<Search className="h-3.5 w-3.5 text-muted shrink-0" />
<input
autoFocus
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="搜索字段…"
className="flex-1 bg-transparent text-xs text-foreground focus:outline-none"
/>
{query && <button onClick={() => setQuery('')} className="text-muted hover:text-foreground"><X className="h-3 w-3" /></button>}
</div>
</div>
{/* 分组列表 */}
<div className="flex-1 overflow-y-auto p-2">
{filteredGroups ? (
filteredGroups.length > 0 ? filteredGroups.map(g => (
<div key={g.key} className="mb-1">
<div className="px-2 py-1 text-[10px] text-muted/60 font-medium">{g.label}</div>
{g.fields.map(f => (
<button
key={f.key}
onClick={() => { onChange(f.key); setOpen(false) }}
className={`w-full text-left px-2.5 py-1.5 rounded text-xs transition-colors ${
f.key === value ? 'bg-accent/10 text-accent' : 'text-foreground/80 hover:bg-elevated'
}`}
>
{f.label}
</button>
))}
</div>
)) : (
<div className="px-3 py-8 text-center text-xs text-muted"></div>
)
) : (
filteredFields.map(f => (
<button
key={f.key}
onClick={() => { onChange(f.key); setOpen(false) }}
className={`w-full text-left px-2.5 py-1.5 rounded text-xs transition-colors ${
f.key === value ? 'bg-accent/10 text-accent' : 'text-foreground/80 hover:bg-elevated'
}`}
>
{f.label}
</button>
))
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
+25 -10
View File
@@ -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() {
<span className="text-secondary">{selectedPreset.description}</span>
{selectedPreset.promo && <span className="text-amber-400">{selectedPreset.promo}</span>}
</div>
<a href={selectedPreset.website} target="_blank" rel="noreferrer"
className="mt-1 inline-flex items-center gap-1 text-muted hover:text-accent transition-colors">
{selectedPreset.websiteLabel}
<ExternalLink className="h-3 w-3" />
</a>
{selectedPreset.website && (
<a href={selectedPreset.website} target="_blank" rel="noreferrer"
className="mt-1 inline-flex items-center gap-1 text-muted hover:text-accent transition-colors">
{selectedPreset.websiteLabel}
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
)}
</Card>