mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
feat(ui): 自定义信号字段选择改居中对话框 + AI配置新增自定义预设
- CustomSignalDialog: FieldPicker 从浮动小面板改为居中对话框(Portal) 条件行 flex-wrap 防截断; 字段按钮弹性宽度 - AI.tsx: 新增"自定义"预设(默认选中, 不自动填充); 新用户未配置AI时字段留空
This commit is contained in:
@@ -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>,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user