diff --git a/backend/app/api/ext_data.py b/backend/app/api/ext_data.py index a2b3ff2..2c3d977 100644 --- a/backend/app/api/ext_data.py +++ b/backend/app/api/ext_data.py @@ -19,9 +19,11 @@ from app.services.ext_data import ( ExtConfigStore, ExtField, PullConfig, + apply_config_mapping, + detect_symbol_candidates, ensure_utf8_csv, fix_symbol_format, - normalize_symbol, + infer_fields_from_df, parse_upload_file, write_ext_parquet, rows_to_parquet, @@ -78,6 +80,16 @@ class PullConfigReq(BaseModel): enabled: bool = False +class DetectUrlReq(BaseModel): + """URL 探测请求,不依赖已存在的扩展配置。""" + url: str = Field(..., min_length=1) + method: str = "GET" + headers: dict[str, str] | None = None + body: str | None = None + response_path: str = "" + field_map: dict[str, str] | None = None + + # --------------------------------------------------------------------------- # 辅助 # --------------------------------------------------------------------------- @@ -95,52 +107,7 @@ def _data_dir(request: Request) -> Path: # --------------------------------------------------------------------------- def _apply_mapping(df: pl.DataFrame, config: ExtConfig, data_dir: Path) -> pl.DataFrame: - """根据 config 的 symbol_map / code_map 自动生成 symbol 和 code 列。 - - 执行顺序:先 mapped(从文件列复制),再 computed(从已生成的列计算)。 - """ - sm = config.symbol_map or {} - cm = config.code_map or {} - - # --- 第一步:mapped 类型,直接从文件列映射 --- - if sm.get("type") == "mapped" and sm["col"] in df.columns: - df = df.with_columns(df[sm["col"]].cast(pl.Utf8).alias("symbol")) - - if cm.get("type") == "mapped" and cm["col"] in df.columns: - df = df.with_columns(df[cm["col"]].cast(pl.Utf8).alias("code")) - - # --- 第二步:computed 类型,从已生成的列计算 --- - if "symbol" not in df.columns and sm.get("type") == "computed": - if sm.get("from") == "code" and "code" in df.columns: - # code → symbol: 000001 → 000001.SZ - from app.services.ext_data import build_code_lookup - lookup = build_code_lookup(data_dir) - df = df.with_columns(normalize_symbol(df["code"].cast(pl.Utf8), lookup).alias("symbol")) - - if "code" not in df.columns and cm.get("type") == "computed": - if cm.get("from") == "symbol" and "symbol" in df.columns: - # symbol → code: 000001.SZ → 000001 - df = df.with_columns( - df["symbol"].cast(pl.Utf8).str.split(".").list.first().alias("code") - ) - - # --- 兜底:如果只有一个,自动生成另一个 --- - if "symbol" in df.columns and "code" not in df.columns: - df = df.with_columns( - df["symbol"].cast(pl.Utf8).str.split(".").list.first().alias("code") - ) - elif "code" in df.columns and "symbol" not in df.columns: - from app.services.ext_data import build_code_lookup - lookup = build_code_lookup(data_dir) - df = df.with_columns(normalize_symbol(df["code"].cast(pl.Utf8), lookup).alias("symbol")) - - # 标准化 symbol 列 - if "symbol" in df.columns: - from app.services.ext_data import build_code_lookup - lookup = build_code_lookup(data_dir) - df = df.with_columns(normalize_symbol(df["symbol"].cast(pl.Utf8), lookup)) - - return df + return apply_config_mapping(df, config, data_dir) def _clean_col_names(df: pl.DataFrame) -> pl.DataFrame: @@ -681,16 +648,6 @@ def fix_symbol(request: Request, config_id: str): # Schema 发现 # --------------------------------------------------------------------------- -_POLARS_TYPE_MAP = { - "Int64": "int", "Int32": "int", "Int16": "int", "Int8": "int", - "UInt64": "int", "UInt32": "int", "UInt16": "int", "UInt8": "int", - "Float64": "float", "Float32": "float", - "Boolean": "bool", - "Utf8": "string", "String": "string", - "Date": "string", "Datetime": "string", "Duration": "string", - "Categorical": "string", -} - @router.post("/detect-fields") async def detect_fields( @@ -729,44 +686,105 @@ async def detect_fields( # 清洗列名:去掉括号内的时间戳等信息 df = _clean_col_names(df) - - # 构建字段列表 - fields = [] - for col_name in df.columns: - pl_type = df[col_name].dtype - dtype = _POLARS_TYPE_MAP.get(str(pl_type.base_type()), "string") - fields.append({"name": col_name, "dtype": dtype, "label": col_name}) - - # --- 分别检测 symbol 候选 (000001.SZ) 和 code 候选 (6位纯数字) --- - import re - _CODE_PAT = re.compile(r"^\d{6}$") - _SYMBOL_PAT = re.compile(r"^\d{6}\.[A-Z]{2}$") - - symbol_candidates: list[str] = [] # 数据匹配 000001.SZ 格式 - code_candidates: list[str] = [] # 数据匹配 000001 格式 - - for col in df.columns: - col_data = df[col].cast(pl.Utf8).drop_nulls() - if len(col_data) == 0: - continue - sample = col_data.head(200).to_list() - sym_hits = sum(1 for v in sample if _SYMBOL_PAT.match(str(v).strip())) - code_hits = sum(1 for v in sample if _CODE_PAT.match(str(v).strip())) - total = len(sample) - if total > 0: - if sym_hits / total > 0.5: - symbol_candidates.append(col) - elif code_hits / total > 0.5: - code_candidates.append(col) + symbol_candidates, code_candidates = detect_symbol_candidates(df) return { - "fields": fields, + "fields": infer_fields_from_df(df), "rows": len(df), "symbol_candidates": symbol_candidates, "code_candidates": code_candidates, } +def _find_row_arrays(data, prefix: str = "", limit: int = 8) -> list[str]: + """自动寻找 JSON 中可能的数据数组路径。""" + found: list[str] = [] + + def walk(value, path: str) -> None: + if len(found) >= limit: + return + if isinstance(value, list): + if value and isinstance(value[0], dict): + found.append(path) + elif value and isinstance(value[0], list): + for i, item in enumerate(value[:3]): + walk(item, f"{path}.{i}" if path else str(i)) + elif isinstance(value, dict): + for key, child in value.items(): + next_path = f"{path}.{key}" if path else key + walk(child, next_path) + + walk(data, prefix) + return found + + +@router.post("/detect-url") +async def detect_url(body: DetectUrlReq): + """请求外部 URL,自动检测 JSON 行数据的字段和标的代码列。""" + from app.services.ext_pull import _extract_rows, _apply_field_map + import httpx + + method = body.method.upper() + if method not in ("GET", "POST"): + raise HTTPException(400, "仅支持 GET / POST") + + try: + async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: + headers = body.headers or {} + kwargs: dict = {"headers": headers} + if method == "POST" and body.body: + kwargs["content"] = body.body + if "content-type" not in {k.lower() for k in headers}: + kwargs["headers"]["Content-Type"] = "application/json" + resp = await client.request(method, body.url, **kwargs) + resp.raise_for_status() + data = resp.json() + except Exception as e: + raise HTTPException(400, f"URL 请求失败: {e}") from e + + path_candidates = _find_row_arrays(data) + response_path = body.response_path + if not response_path: + if not path_candidates: + raise HTTPException(400, "未在响应中找到对象数组,请填写响应数据路径") + response_path = path_candidates[0] + + try: + rows = _extract_rows(data, response_path) + rows = _apply_field_map(rows, body.field_map or {}) + except Exception as e: + raise HTTPException(400, f"响应解析失败: {e}") from e + + if not rows: + raise HTTPException(400, "提取到的行数为 0") + if not all(isinstance(row, dict) for row in rows[:200]): + raise HTTPException(400, "响应数据数组中的元素必须是对象") + + sample_rows = rows[: min(len(rows), 500)] + try: + df = pl.DataFrame(sample_rows) + except Exception as e: + raise HTTPException(400, f"样例数据解析失败: {e}") from e + + df = _clean_col_names(df) + symbol_candidates, code_candidates = detect_symbol_candidates(df) + preview = [ + {k: _safe_json_value(v) for k, v in row.items()} + for row in df.head(10).to_dicts() + ] + + return { + "status": "ok", + "total_rows": len(rows), + "response_path": response_path, + "response_path_candidates": path_candidates, + "fields": infer_fields_from_df(df), + "symbol_candidates": symbol_candidates, + "code_candidates": code_candidates, + "preview": preview, + } + + @router.get("/schema/{config_id}") def discover_schema(request: Request, config_id: str): """发现扩展数据的实际 Parquet schema(基于已有数据)。""" diff --git a/backend/app/services/ext_data.py b/backend/app/services/ext_data.py index 7e7695d..86af142 100644 --- a/backend/app/services/ext_data.py +++ b/backend/app/services/ext_data.py @@ -3,6 +3,7 @@ from __future__ import annotations import json import logging +import re from datetime import date, datetime from pathlib import Path from typing import Literal @@ -266,6 +267,54 @@ _POLARS_DTYPE_MAP = { "bool": pl.Boolean, } +_POLARS_TYPE_MAP = { + "Int64": "int", "Int32": "int", "Int16": "int", "Int8": "int", + "UInt64": "int", "UInt32": "int", "UInt16": "int", "UInt8": "int", + "Float64": "float", "Float32": "float", + "Boolean": "bool", + "Utf8": "string", "String": "string", + "Date": "string", "Datetime": "string", "Duration": "string", + "Categorical": "string", +} + +_CODE_PAT = re.compile(r"^\d{6}$") +_SYMBOL_PAT = re.compile(r"^\d{6}\.[A-Z]{2}$") + + +def infer_fields_from_df(df: pl.DataFrame) -> list[dict]: + """从 DataFrame 推断扩展字段定义。""" + fields = [] + for col_name in df.columns: + pl_type = df[col_name].dtype + dtype = _POLARS_TYPE_MAP.get(str(pl_type.base_type()), "string") + fields.append({"name": col_name, "dtype": dtype, "label": col_name}) + return fields + + +def detect_symbol_candidates(df: pl.DataFrame) -> tuple[list[str], list[str]]: + """识别 symbol/code 候选列。""" + symbol_candidates: list[str] = [] + code_candidates: list[str] = [] + + for col in df.columns: + try: + col_data = df[col].cast(pl.Utf8).drop_nulls() + except Exception: + continue + if len(col_data) == 0: + continue + sample = col_data.head(200).to_list() + sym_hits = sum(1 for v in sample if _SYMBOL_PAT.match(str(v).strip())) + code_hits = sum(1 for v in sample if _CODE_PAT.match(str(v).strip())) + total = len(sample) + if total > 0: + if sym_hits / total > 0.5: + symbol_candidates.append(col) + elif code_hits / total > 0.5: + code_candidates.append(col) + + return symbol_candidates, code_candidates + def build_code_lookup(data_dir: Path) -> dict[str, str]: """从 instruments 维表构建 code → symbol 映射。""" @@ -309,6 +358,43 @@ def normalize_symbol(series: pl.Series, lookup: dict[str, str] | None = None) -> return series.map_elements(_fix_one, return_dtype=pl.Utf8) +def apply_config_mapping(df: pl.DataFrame, config: ExtConfig, data_dir: Path) -> pl.DataFrame: + """根据 config 的 symbol_map / code_map 自动生成 symbol 和 code 列。""" + sm = config.symbol_map or {} + cm = config.code_map or {} + + if sm.get("type") == "mapped" and sm["col"] in df.columns: + df = df.with_columns(df[sm["col"]].cast(pl.Utf8).alias("symbol")) + + if cm.get("type") == "mapped" and cm["col"] in df.columns: + df = df.with_columns(df[cm["col"]].cast(pl.Utf8).alias("code")) + + if "symbol" not in df.columns and sm.get("type") == "computed": + if sm.get("from") == "code" and "code" in df.columns: + lookup = build_code_lookup(data_dir) + df = df.with_columns(normalize_symbol(df["code"].cast(pl.Utf8), lookup).alias("symbol")) + + if "code" not in df.columns and cm.get("type") == "computed": + if cm.get("from") == "symbol" and "symbol" in df.columns: + df = df.with_columns( + df["symbol"].cast(pl.Utf8).str.split(".").list.first().alias("code") + ) + + if "symbol" in df.columns and "code" not in df.columns: + df = df.with_columns( + df["symbol"].cast(pl.Utf8).str.split(".").list.first().alias("code") + ) + elif "code" in df.columns and "symbol" not in df.columns: + lookup = build_code_lookup(data_dir) + df = df.with_columns(normalize_symbol(df["code"].cast(pl.Utf8), lookup).alias("symbol")) + + if "symbol" in df.columns: + lookup = build_code_lookup(data_dir) + df = df.with_columns(normalize_symbol(df["symbol"].cast(pl.Utf8), lookup)) + + return df + + def ensure_utf8_csv(file_path: Path) -> Path: """确保 CSV 文件以 UTF-8 编码可读,非 UTF-8(如 GBK/GB18030)则转换。 @@ -516,6 +602,7 @@ def rows_to_parquet( 写入行数。 """ df = pl.DataFrame(rows) + df = apply_config_mapping(df, config, data_dir) if "symbol" in df.columns: df = df.with_columns(pl.col("symbol").cast(pl.Utf8)) return write_ext_parquet(df, config, data_dir, snapshot_date=snapshot_date) diff --git a/backend/app/services/ext_pull.py b/backend/app/services/ext_pull.py index 5f04ea7..cfd2b99 100644 --- a/backend/app/services/ext_pull.py +++ b/backend/app/services/ext_pull.py @@ -133,9 +133,15 @@ async def fetch_and_ingest( # 字段映射 rows = _apply_field_map(rows, pull.field_map) - # 校验 symbol 列 - if rows and "symbol" not in rows[0]: - raise ValueError("数据行中缺少 symbol 字段,请配置 field_map 映射") + # 校验可关联标的的字段:直接 symbol/code,或配置里声明的映射源列。 + row_keys = set(rows[0]) if rows else set() + mapped_cols = { + m.get("col") + for m in (config.symbol_map or {}, config.code_map or {}) + if m.get("type") == "mapped" and m.get("col") + } + if rows and not ({"symbol", "code"} & row_keys or mapped_cols & row_keys): + raise ValueError("数据行中缺少 symbol/code 字段,请配置字段映射或标的映射") # 写入 snap = date.today() diff --git a/frontend/src/components/ext-data/CreateExtDialog.tsx b/frontend/src/components/ext-data/CreateExtDialog.tsx index 2c7c995..5947436 100644 --- a/frontend/src/components/ext-data/CreateExtDialog.tsx +++ b/frontend/src/components/ext-data/CreateExtDialog.tsx @@ -1,17 +1,38 @@ import { useRef, useState } from 'react' import { useMutation, useQueryClient } from '@tanstack/react-query' import { motion } from 'framer-motion' -import { X, Loader2, Upload, Plus, AlertCircle, Tag, Clock } from 'lucide-react' -import { api, type ExtDataField } from '@/lib/api' +import { + X, + Loader2, + Upload, + Plus, + AlertCircle, + Tag, + Clock, + Link2, + FileText, + Keyboard, + RefreshCw, +} from 'lucide-react' +import { api, type ExtDataDetectUrlResult, type ExtDataField } from '@/lib/api' import { QK } from '@/lib/queryKeys' +type SourceMode = 'url' | 'file' | 'manual' + +type MappingChoice = { + fields: { name: string; dtype: string; label: string }[] + need: 'symbol' | 'code' | 'both' +} + export function CreateExtDialog({ onClose }: { onClose: () => void }) { const qc = useQueryClient() + const [sourceMode, setSourceMode] = useState('url') const [id, setId] = useState('') const [label, setLabel] = useState('') const [description, setDescription] = useState('') const [mode, setMode] = useState<'snapshot' | 'timeseries'>('snapshot') const [fields, setFields] = useState([]) + const [detectedSourceNames, setDetectedSourceNames] = useState([]) const [error, setError] = useState('') const detectFileRef = useRef(null) const [detecting, setDetecting] = useState(false) @@ -19,12 +40,74 @@ export function CreateExtDialog({ onClose }: { onClose: () => void }) { const [symbolMap, setSymbolMap] = useState>({}) const [codeMap, setCodeMap] = useState>({}) const [matchStatus, setMatchStatus] = useState<'none' | 'partial' | 'full'>('none') - const [selectMapping, setSelectMapping] = useState<{ fields: { name: string; dtype: string; label: string }[]; need: 'symbol' | 'code' | 'both' } | null>(null) + const [selectMapping, setSelectMapping] = useState(null) + + const [url, setUrl] = useState('') + const [method, setMethod] = useState<'GET' | 'POST'>('GET') + const [headerStr, setHeaderStr] = useState('') + const [body, setBody] = useState('') + const [responsePath, setResponsePath] = useState('') + const [fieldMapStr, setFieldMapStr] = useState('') + const [schedule, setSchedule] = useState(1440) + const [savePull, setSavePull] = useState(true) + const [importNow, setImportNow] = useState(true) + const [enablePull, setEnablePull] = useState(false) + const [urlPreview, setUrlPreview] = useState(null) + + const userFields = fields.filter(f => f.name !== 'symbol' && f.name !== 'code') + + const parseJsonObject = (str: string, labelText: string): Record | undefined => { + if (!str.trim()) return undefined + try { + const parsed = JSON.parse(str) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('not object') + } + return Object.fromEntries( + Object.entries(parsed).map(([k, v]) => [k, String(v)]), + ) + } catch { + throw new Error(`${labelText} 不是有效 JSON 对象`) + } + } + + const buildPullFieldMap = () => { + const base = parseJsonObject(fieldMapStr, '字段映射') ?? {} + const finalMap: Record = { ...base } + + fields.forEach((field, index) => { + const source = detectedSourceNames[index] + const target = field.name.trim() + if (!source || !target || source === target) return + + const upstreamKeys = Object.entries(base) + .filter(([, mapped]) => mapped === source) + .map(([raw]) => raw) + + if (upstreamKeys.length) { + upstreamKeys.forEach((raw) => { finalMap[raw] = target }) + } else { + finalMap[source] = target + } + }) + + return finalMap + } + + const mapSubmitCol = (map: Record, fieldMap: Record) => { + if (sourceMode !== 'url' || map.type !== 'mapped' || !map.col) return map + const mappedCol = fieldMap[map.col] ?? map.col + return { ...map, col: mappedCol } + } const create = useMutation({ - mutationFn: () => { + mutationFn: async () => { const userF = fields.filter((f) => f.name.trim() && f.name !== 'symbol' && f.name !== 'code') - return api.extDataCreate({ + const pullFieldMap = sourceMode === 'url' ? buildPullFieldMap() : {} + const submittedSymbolMap = mapSubmitCol(symbolMap, pullFieldMap) + const submittedCodeMap = mapSubmitCol(codeMap, pullFieldMap) + + const config = await api.extDataCreate({ id, label, mode, @@ -34,27 +117,60 @@ export function CreateExtDialog({ onClose }: { onClose: () => void }) { ...userF, ], description: description.trim() || undefined, - symbol_map: symbolMap, - code_map: codeMap, + symbol_map: submittedSymbolMap, + code_map: submittedCodeMap, }) + + if (sourceMode === 'url' && (savePull || importNow || enablePull)) { + const headers = parseJsonObject(headerStr, 'Headers') + const finalFieldMap = Object.keys(pullFieldMap).length ? pullFieldMap : undefined + await api.extDataPullConfig(config.id, { + url: url.trim(), + method, + headers, + body: method === 'POST' && body.trim() ? body : undefined, + response_path: responsePath.trim() || urlPreview?.response_path || '', + field_map: finalFieldMap, + schedule_minutes: schedule, + enabled: enablePull, + }) + + if (importNow) { + await api.extDataPullRun(config.id) + } + } + + return config }, onSuccess: () => { qc.invalidateQueries({ queryKey: QK.extData }) + qc.invalidateQueries({ queryKey: QK.dataStatus }) onClose() }, - onError: (err) => setError(String(err)), + onError: (err) => setError(err instanceof Error ? err.message : String(err)), }) - const addField = () => + const addField = () => { setFields([...fields, { name: '', dtype: 'string', label: '' }]) + setDetectedSourceNames([...detectedSourceNames, '']) + } - const removeField = (i: number) => + const removeField = (i: number) => { setFields(fields.filter((_, idx) => idx !== i)) + setDetectedSourceNames(detectedSourceNames.filter((_, idx) => idx !== i)) + } - const updateField = (i: number, key: keyof ExtDataField, val: string) => + const updateField = (i: number, key: keyof ExtDataField, val: string) => { setFields(fields.map((f, idx) => (idx === i ? { ...f, [key]: val } : f))) + } - const valid = id.trim() && label.trim() && fields.some((f) => f.name.trim()) && matchStatus !== 'none' + const valid = Boolean( + id.trim() + && label.trim() + && fields.some((f) => f.name.trim()) + && matchStatus !== 'none' + && (sourceMode !== 'url' || url.trim()), + ) const processDetection = ( detected: { name: string; dtype: string; label: string }[], @@ -65,6 +181,8 @@ export function CreateExtDialog({ onClose }: { onClose: () => void }) { let cm: Record = {} let status: 'none' | 'partial' | 'full' = 'none' + setDetectedSourceNames(detected.map(f => f.name)) + if (symCands.length === 1 && codeCands.length === 1) { sm = { type: 'mapped', col: symCands[0] } cm = { type: 'mapped', col: codeCands[0] } @@ -97,11 +215,12 @@ export function CreateExtDialog({ onClose }: { onClose: () => void }) { setSelectMapping(null) } - const handleDetectFile = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - e.target.value = '' - setDetecting(true); setError(''); setSelectMapping(null) + const detectFile = (file: File) => { + setSourceMode('file') + setDetecting(true) + setError('') + setSelectMapping(null) + setUrlPreview(null) api.extDataDetectFields(file) .then((res) => { processDetection(res.fields, res.symbol_candidates, res.code_candidates) @@ -110,7 +229,82 @@ export function CreateExtDialog({ onClose }: { onClose: () => void }) { .finally(() => setDetecting(false)) } - const userFields = fields.filter(f => f.name !== 'symbol' && f.name !== 'code') + const handleDetectFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + e.target.value = '' + detectFile(file) + } + + const handleDetectUrl = () => { + if (!url.trim()) { + setError('请先填写 URL') + return + } + setDetecting(true) + setError('') + setSelectMapping(null) + setUrlPreview(null) + + let headers: Record | undefined + let fieldMap: Record | undefined + try { + headers = parseJsonObject(headerStr, 'Headers') + fieldMap = parseJsonObject(fieldMapStr, '字段映射') + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + setDetecting(false) + return + } + + api.extDataDetectUrl({ + url: url.trim(), + method, + headers, + body: method === 'POST' && body.trim() ? body : undefined, + response_path: responsePath.trim() || undefined, + field_map: fieldMap, + }) + .then((res) => { + setUrlPreview(res) + setResponsePath(res.response_path || responsePath) + processDetection(res.fields, res.symbol_candidates, res.code_candidates) + }) + .catch((err) => setError(String(err))) + .finally(() => setDetecting(false)) + } + + const selectSource = (next: SourceMode) => { + if (next !== sourceMode) { + setFields([]) + setDetectedSourceNames([]) + setSymbolMap({}) + setCodeMap({}) + setMatchStatus('none') + setSelectMapping(null) + setUrlPreview(null) + } + setSourceMode(next) + setError('') + if (next === 'manual') { + setSymbolMap({ type: 'mapped', col: 'symbol' }) + setCodeMap({ type: 'computed', from: 'symbol', method: 'strip_exchange' }) + setMatchStatus('full') + } + } + + const applyManualMapping = (fieldName: string) => { + const sm = { type: 'mapped', col: fieldName } + const cm = { type: 'computed', from: 'symbol', method: 'strip_exchange' } + setSymbolMap(sm) + setCodeMap(cm) + setMatchStatus('full') + setSelectMapping(null) + } + + const previewColumns = urlPreview?.preview?.[0] + ? Object.keys(urlPreview.preview[0]).slice(0, 5) + : [] return (
@@ -120,14 +314,14 @@ export function CreateExtDialog({ onClose }: { onClose: () => void }) { animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.97, y: 8 }} transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }} - className="relative rounded-2xl border border-border bg-surface shadow-2xl mx-4 w-full max-w-xl max-h-[85vh] flex flex-col overflow-hidden" + className="relative rounded-2xl border border-border bg-surface shadow-2xl mx-4 w-full max-w-2xl max-h-[88vh] flex flex-col overflow-hidden" >

新增扩展数据

- 接入自有数据,与标的自动关联(第三方接口或CSV/Excel),支持概念、人气、资金流、舆情、研报评分标签等场景 + 接入自有数据,与标的自动关联(第三方接口或 CSV/Excel),支持概念、人气、资金流、舆情、研报评分标签等场景

+
+
接入方式
+
+ {([ + ['url', 'URL', Link2], + ['file', '文件', FileText], + ['manual', '手动', Keyboard], + ] as const).map(([key, text, Icon]) => { + const active = sourceMode === key + return ( + + ) + })} +
+
+
数据类型
@@ -204,8 +423,151 @@ export function CreateExtDialog({ onClose }: { onClose: () => void }) { />
+ {sourceMode === 'url' && ( +
+
+ + URL 请求配置 +
+
+ + setUrl(e.target.value)} + placeholder="https://api.example.com/data" + className="h-8 flex-1 min-w-0 rounded-lg border border-border bg-base px-3 text-[11px] font-mono text-foreground placeholder:text-muted/40 focus:outline-none focus:border-accent/50" + /> + +
+
+
+
响应数据路径
+ setResponsePath(e.target.value)} + placeholder="data.list(可留空自动识别)" + className="w-full h-8 rounded-lg border border-border bg-base px-2 text-[10px] font-mono text-foreground placeholder:text-muted/40 focus:outline-none focus:border-accent/50" + /> +
+
+
调度间隔(分钟)
+ setSchedule(Number(e.target.value) || 1)} + className="w-full h-8 rounded-lg border border-border bg-base px-2 text-[10px] font-mono text-foreground focus:outline-none focus:border-accent/50" + /> +
+
+
+
+
Headers(JSON,可选)
+