mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 14:24:15 +08:00
Merge pull request #138 from intfoo/feat/custom-source-timeout-param-ui
自定义数据源超时与请求参数名支持前端配置
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -149,6 +149,16 @@ freq_param: period
|
||||
|
||||
配置后,分钟请求会分别传入 `stock` / `etf` / `index` 和 `1m`;留空时不向上游发送这两个参数,以兼容已有数据源。
|
||||
|
||||
### 请求超时
|
||||
|
||||
每个数据集可单独配置请求超时(秒),默认 30:
|
||||
|
||||
```yaml
|
||||
timeout: 60
|
||||
```
|
||||
|
||||
留空或省略时用默认 30 秒;该值对数据同步与「试拉测试」均生效。在设置页编辑数据源时可在「超时」输入框修改(与 批量 / RPM / 响应路径 同行)。
|
||||
|
||||
## 鉴权
|
||||
|
||||
支持三种简单鉴权:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user