Merge pull request #138 from intfoo/feat/custom-source-timeout-param-ui

自定义数据源超时与请求参数名支持前端配置
This commit is contained in:
wshy
2026-07-25 22:01:11 +08:00
committed by GitHub
6 changed files with 153 additions and 22 deletions
+1
View File
@@ -373,6 +373,7 @@ class DatasetConfigIn(BaseModel):
end_param: str = "end_time"
asset_type_param: str | None = None
freq_param: str | None = None
timeout: float | None = None
class AuthConfigIn(BaseModel):
@@ -290,6 +290,7 @@ def _config_to_dict(config: CustomSourceConfig) -> dict:
"method": ds.method,
**({"batch": ds.batch} if ds.batch is not None else {}),
**({"rpm": ds.rpm} if ds.rpm is not None else {}),
**({"timeout": ds.timeout} if ds.timeout != 30.0 else {}),
"response_path": ds.response_path,
"field_map": dict(ds.field_map),
**({"transforms": dict(ds.transforms)} if ds.transforms else {}),
@@ -382,6 +383,11 @@ def _sanitize_dataset(ds_cfg: dict) -> dict:
out["rpm"] = int(ds_cfg["rpm"])
except (TypeError, ValueError):
pass
if ds_cfg.get("timeout") is not None:
try:
out["timeout"] = float(ds_cfg["timeout"])
except (TypeError, ValueError):
pass
out["response_path"] = str(ds_cfg.get("response_path", "") or "")
field_map = {
str(k): str(v)
@@ -27,3 +27,43 @@ def test_minute_request_parameter_names_survive_config_round_trip():
assert parsed.freq_param == "period"
assert exposed["datasets"]["minute"]["asset_type_param"] == "asset"
assert exposed["datasets"]["minute"]["freq_param"] == "period"
def test_timeout_survives_config_round_trip():
"""timeout 必须在 UI 保存往返中保留 (核心修复), 且默认 30 不污染 YAML。"""
dataset = DatasetConfigIn(
url="https://example.test/daily",
method="POST",
timeout=120.0,
).model_dump()
cleaned = _sanitize_for_yaml({
"name": "test_source",
"display_name": "Test Source",
"datasets": {"daily": dataset},
})
parsed = _dataset_from_dict(cleaned["datasets"]["daily"])
exposed = _config_to_dict(CustomSourceConfig(
name="test_source",
display_name="Test Source",
datasets={"daily": parsed},
))
assert parsed.timeout == 120.0
assert exposed["datasets"]["daily"]["timeout"] == 120.0
# 默认 30 不 emit, 保持 YAML 干净
default_dataset = DatasetConfigIn(url="https://example.test/realtime", method="GET").model_dump()
cleaned2 = _sanitize_for_yaml({
"name": "test_source",
"display_name": "Test Source",
"datasets": {"realtime": default_dataset},
})
parsed2 = _dataset_from_dict(cleaned2["datasets"]["realtime"])
exposed2 = _config_to_dict(CustomSourceConfig(
name="test_source",
display_name="Test Source",
datasets={"realtime": parsed2},
))
assert parsed2.timeout == 30.0
assert "timeout" not in exposed2["datasets"]["realtime"]
+10
View File
@@ -149,6 +149,16 @@ freq_param: period
配置后,分钟请求会分别传入 `stock` / `etf` / `index``1m`;留空时不向上游发送这两个参数,以兼容已有数据源。
### 请求超时
每个数据集可单独配置请求超时(秒),默认 30:
```yaml
timeout: 60
```
留空或省略时用默认 30 秒;该值对数据同步与「试拉测试」均生效。在设置页编辑数据源时可在「超时」输入框修改(与 批量 / RPM / 响应路径 同行)。
## 鉴权
支持三种简单鉴权:
+1
View File
@@ -842,6 +842,7 @@ export interface DatasetConfig {
end_param?: string
asset_type_param?: string | null
freq_param?: string | null
timeout?: number | null
}
export interface AuthConfig {
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { KeyRound, Play, Plus, Save, Trash2, X, Zap, Check } from 'lucide-react'
import { KeyRound, Play, Plus, Save, Trash2, X, Zap, Check, ChevronDown } from 'lucide-react'
import { api, type CustomSourceConfig, type DatasetConfig } from '@/lib/api'
import { toast } from '@/components/Toast'
@@ -319,6 +319,8 @@ function DatasetDetail({
}) {
const enabled = !!cfg
const [testSymbols, setTestSymbols] = useState('000001.SZ,600000.SH')
const [showParams, setShowParams] = useState(false)
const showTimeParams = datasetKey !== 'realtime'
const test = useMutation({
mutationFn: () => api.testDataSource(
providerName,
@@ -368,7 +370,7 @@ function DatasetDetail({
</Field>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
<Field label="批量">
<input
value={cfg.batch ?? ''}
@@ -385,6 +387,16 @@ function DatasetDetail({
className={`${INPUT_CLS} w-full`}
/>
</Field>
<Field label="超时">
<input
type="number"
value={cfg.timeout ?? ''}
onChange={e => onUpdate({ timeout: e.target.value ? Number(e.target.value) : null })}
onWheel={e => e.currentTarget.blur()}
placeholder="30"
className={`${INPUT_CLS} w-full`}
/>
</Field>
<Field label="响应路径">
<input
value={cfg.response_path}
@@ -395,30 +407,91 @@ function DatasetDetail({
</Field>
</div>
{datasetKey === 'minute' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Field label="资产类型参数">
<input
value={cfg.asset_type_param ?? ''}
onChange={e => onUpdate({ asset_type_param: e.target.value || null })}
placeholder="asset_type"
className={`${INPUT_CLS} w-full`}
/>
</Field>
<Field label="周期参数">
<input
value={cfg.freq_param ?? ''}
onChange={e => onUpdate({ freq_param: e.target.value || null })}
placeholder="period"
className={`${INPUT_CLS} w-full`}
/>
</Field>
{/* 请求参数字段映射 — 折叠区 */}
<div>
<button
type="button"
onClick={() => setShowParams(v => !v)}
className="w-full flex items-center gap-1.5 mb-2"
>
<span className="text-[10px] uppercase tracking-widest text-muted"></span>
<ChevronDown className={`h-3 w-3 text-muted transition-transform ${showParams ? 'rotate-180' : ''}`} />
</button>
<div className="text-[10px] text-muted/50 mb-1.5">
</div>
)}
<AnimatePresence initial={false}>
{showParams && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 pt-1">
{showTimeParams && (
<>
<Field label="代码参数">
<input
value={cfg.symbols_param ?? ''}
onChange={e => onUpdate({ symbols_param: e.target.value || undefined })}
placeholder="symbols"
className={`${INPUT_CLS} w-full`}
/>
</Field>
<Field label="起始时间参数">
<input
value={cfg.start_param ?? ''}
onChange={e => onUpdate({ start_param: e.target.value || undefined })}
placeholder="start_time"
className={`${INPUT_CLS} w-full`}
/>
</Field>
<Field label="结束时间参数">
<input
value={cfg.end_param ?? ''}
onChange={e => onUpdate({ end_param: e.target.value || undefined })}
placeholder="end_time"
className={`${INPUT_CLS} w-full`}
/>
</Field>
</>
)}
{datasetKey === 'minute' && (
<>
<Field label="资产类型参数">
<input
value={cfg.asset_type_param ?? ''}
onChange={e => onUpdate({ asset_type_param: e.target.value || null })}
placeholder="asset_type"
className={`${INPUT_CLS} w-full`}
/>
</Field>
<Field label="周期参数">
<input
value={cfg.freq_param ?? ''}
onChange={e => onUpdate({ freq_param: e.target.value || null })}
placeholder="period"
className={`${INPUT_CLS} w-full`}
/>
</Field>
</>
)}
{datasetKey === 'realtime' && (
<div className="col-span-full text-[10px] text-muted/50">
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<div className="text-[10px] uppercase tracking-widest text-muted"></div>
<div className="text-[10px] uppercase tracking-widest text-muted"></div>
<a
href="https://github.com/shy3130/tickflow-stock-panel/blob/main/docs/custom-data-source.md#用-ai-生成映射配置"
target="_blank"