Files
stock/local/frontend/src/components/ext-data/ExtDataPullPanel.tsx
T
2026-07-04 16:32:48 +08:00

327 lines
14 KiB
TypeScript

import { useState } from 'react'
import { Loader2, Search, Check, Clock, Zap, Settings2, AlertCircle, CheckCircle2, Calendar } from 'lucide-react'
import { api, type ExtDataConfig } from '@/lib/api'
import { toast } from '@/components/Toast'
export function ExtDataPullPanel({ config, onSaved }: {
config: ExtDataConfig
onSaved: () => void
}) {
const pull = config.pull
const [url, setUrl] = useState(pull?.url ?? '')
const [method, setMethod] = useState(pull?.method ?? 'GET')
const [headerStr, setHeaderStr] = useState(
pull?.headers ? JSON.stringify(pull.headers, null, 2) : ''
)
const [body, setBody] = useState(pull?.body ?? '')
const [responsePath, setResponsePath] = useState(pull?.response_path ?? '')
const [fieldMapStr, setFieldMapStr] = useState(
pull?.field_map ? JSON.stringify(pull.field_map, null, 2) : ''
)
const [schedule, setSchedule] = useState(pull?.schedule_minutes ?? 1440)
const [enabled, setEnabled] = useState(pull?.enabled ?? false)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [running, setRunning] = useState(false)
const [runResult, setRunResult] = useState<{ rows: number; date: string } | null>(null)
const [testResult, setTestResult] = useState<{ total_rows: number; preview: Record<string, unknown>[]; has_symbol: boolean } | null>(null)
const [error, setError] = useState('')
// 解析 JSON 输入, 失败时设置 error 并返回 null
const parseJson = (str: string, label: string): Record<string, string> | undefined | null => {
if (!str.trim()) return undefined
try { return JSON.parse(str) }
catch { setError(`${label} 不是有效 JSON`); return null }
}
// 构建保存 payload (复用当前编辑态), enabledOverride 用于开关自动保存
const buildPayload = (enabledOverride?: boolean) => {
const headers = parseJson(headerStr, 'Headers')
if (headers === null) return null
const field_map = parseJson(fieldMapStr, '字段映射')
if (field_map === null) return null
return {
url, method, headers, body: body || undefined,
response_path: responsePath, field_map,
schedule_minutes: schedule, enabled: enabledOverride ?? enabled,
}
}
const handleSave = (silent = false) => {
const payload = buildPayload()
if (!payload) return
setSaving(true); setError('')
api.extDataPullConfig(config.id, payload)
.then(() => {
onSaved()
if (!silent) toast('配置已保存', 'success')
})
.catch(e => setError(e.message || '保存失败'))
.finally(() => setSaving(false))
}
const handleTest = () => {
setTesting(true); setError(''); setTestResult(null)
const payload = buildPayload()
if (!payload) { setTesting(false); return }
api.extDataPullConfig(config.id, payload)
.then(() => api.extDataPullTest(config.id))
.then(r => { setTestResult(r); onSaved() })
.catch(e => setError(e.message || '测试失败'))
.finally(() => setTesting(false))
}
const handleRun = () => {
setRunning(true); setError(''); setRunResult(null)
api.extDataPullRun(config.id)
.then(r => {
setRunResult({ rows: r.rows, date: r.date })
onSaved()
toast(`拉取成功 · ${r.rows} 行`, 'success')
})
.catch(e => setError(e.message || '执行失败'))
.finally(() => setRunning(false))
}
// 开关 toggle: 自动保存全量配置 (切换 enabled), 后端 refresh 后立即首次拉取
const [toggling, setToggling] = useState(false)
const handleToggle = (next: boolean) => {
if (toggling) return
if (next && !url.trim()) {
toast('请先填写拉取 URL', 'error')
return
}
const payload = buildPayload(next)
if (!payload) return
setToggling(true); setError(''); setEnabled(next)
api.extDataPullConfig(config.id, payload)
.then(() => {
onSaved()
toast(next ? '定时拉取已启用 · 立即执行首次拉取' : '定时拉取已关闭', 'success')
})
.catch(e => {
setEnabled(!next) // 回滚
setError(e.message || '切换失败')
})
.finally(() => setToggling(false))
}
// 格式化时间显示
const fmtTime = (iso: string | null | undefined) => {
if (!iso) return null
const d = new Date(iso)
if (isNaN(d.getTime())) return null
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mi = String(d.getMinutes()).padStart(2, '0')
return `${mm}-${dd} ${hh}:${mi}`
}
return (
<div className="space-y-3">
{/* ===== 分区 ①: 请求配置 ===== */}
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-secondary">
<Settings2 className="h-3 w-3 text-muted" />
<span>请求配置</span>
</div>
<div className="flex gap-1.5">
<select
value={method} onChange={e => setMethod(e.target.value)}
className="shrink-0 rounded-btn border border-border bg-elevated px-2 py-1.5 text-[11px] text-foreground"
>
<option value="GET">GET</option>
<option value="POST">POST</option>
</select>
<input
value={url} onChange={e => setUrl(e.target.value)}
placeholder="https://api.example.com/data"
className="flex-1 min-w-0 rounded-btn border border-border bg-elevated px-2.5 py-1.5 text-[11px] font-mono text-foreground placeholder:text-muted/50"
/>
</div>
<div>
<div className="text-[10px] text-muted mb-1">Headers (JSON,可选)</div>
<textarea
value={headerStr} onChange={e => setHeaderStr(e.target.value)}
placeholder='{"Authorization": "Bearer xxx"}'
rows={2}
className="w-full rounded-btn border border-border bg-elevated px-2.5 py-1.5 text-[10px] font-mono text-foreground placeholder:text-muted/40 resize-none"
/>
</div>
{method === 'POST' && (
<div>
<div className="text-[10px] text-muted mb-1">请求体 (JSON,可选)</div>
<textarea
value={body} onChange={e => setBody(e.target.value)}
placeholder='{"page": 1}'
rows={2}
className="w-full rounded-btn border border-border bg-elevated px-2.5 py-1.5 text-[10px] font-mono text-foreground placeholder:text-muted/40 resize-none"
/>
</div>
)}
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-[10px] text-muted mb-1">响应数据路径</div>
<input
value={responsePath} onChange={e => setResponsePath(e.target.value)}
placeholder="data.list"
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-[10px] font-mono text-foreground placeholder:text-muted/40"
/>
</div>
<div>
<div className="text-[10px] text-muted mb-1">调度间隔 (分钟)</div>
<input
type="number" min={1} value={schedule} onChange={e => setSchedule(Number(e.target.value))}
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-[10px] font-mono text-foreground"
/>
</div>
</div>
<div>
<div className="text-[10px] text-muted mb-1">字段映射 (外部名 内部名,JSON,可选)</div>
<textarea
value={fieldMapStr} onChange={e => setFieldMapStr(e.target.value)}
placeholder='{"code": "symbol", "val": "score"}'
rows={2}
className="w-full rounded-btn border border-border bg-elevated px-2.5 py-1.5 text-[10px] font-mono text-foreground placeholder:text-muted/40 resize-none"
/>
</div>
</div>
{/* ===== 分区 ②: 定时拉取状态 ===== */}
<div className="rounded-card border border-border/60 bg-elevated/30 p-2.5 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-secondary">
<Clock className="h-3 w-3 text-muted" />
<span>定时拉取</span>
</div>
{/* 自定义 Toggle 开关 */}
<button
type="button"
role="switch"
aria-checked={enabled}
disabled={toggling}
onClick={() => handleToggle(!enabled)}
className={`relative inline-flex h-4 w-7 shrink-0 items-center rounded-full transition-colors duration-200 disabled:opacity-50 ${
enabled ? 'bg-accent' : 'bg-border'
}`}
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white shadow transition-transform duration-200 ${
enabled ? 'translate-x-3.5' : 'translate-x-0.5'
}`}
/>
</button>
</div>
{/* 状态文案 */}
<div className="text-[10px] leading-relaxed">
{enabled ? (
<div className="space-y-0.5">
<div className="flex items-center gap-1 text-accent">
<span className="h-1 w-1 rounded-full bg-accent animate-pulse" />
<span>已启用 · {schedule} 分钟</span>
</div>
{pull?.next_run && fmtTime(pull.next_run) && (
<div className="flex items-center gap-1 text-muted">
<Calendar className="h-2.5 w-2.5" />
<span>下次:{fmtTime(pull.next_run)}</span>
</div>
)}
</div>
) : (
<div className="text-muted">未启用 · 仅手动执行</div>
)}
</div>
{/* 上次执行结果 */}
{pull?.last_run && (
<div className="flex items-start gap-1.5 pt-1.5 border-t border-border/40">
{pull.last_status === 'success' ? (
<CheckCircle2 className="h-3 w-3 text-emerald-500 shrink-0 mt-px" />
) : (
<AlertCircle className="h-3 w-3 text-danger shrink-0 mt-px" />
)}
<div className="min-w-0 flex-1">
<div className={`text-[10px] font-medium ${pull.last_status === 'success' ? 'text-emerald-500' : 'text-danger'}`}>
{pull.last_message || (pull.last_status === 'success' ? '成功' : '失败')}
</div>
{fmtTime(pull.last_run) && (
<div className="text-[9px] text-muted">{fmtTime(pull.last_run)}</div>
)}
</div>
</div>
)}
</div>
{/* ===== 分区 ③: 操作按钮 ===== */}
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<button
onClick={handleTest}
disabled={testing || !url}
className="inline-flex items-center justify-center gap-1 px-2 py-2 rounded-btn border border-border bg-elevated text-xs text-foreground hover:bg-border/30 disabled:opacity-40 transition-colors"
>
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Search className="h-3.5 w-3.5" />}
测试
</button>
<button
onClick={handleRun}
disabled={running || !url}
className="inline-flex items-center justify-center gap-1 px-2 py-2 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 transition-colors"
>
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Zap className="h-3.5 w-3.5" />}
立即执行
</button>
</div>
<button
onClick={() => handleSave(false)}
disabled={saving || !url}
className="w-full inline-flex items-center justify-center gap-1 py-2 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 transition-colors"
>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
保存配置
</button>
</div>
{/* ===== 结果展示 ===== */}
{runResult && (
<div className="rounded-card border border-emerald-500/30 bg-emerald-500/[0.06] p-2.5 flex items-center justify-between text-[10px]">
<span className="text-emerald-500 font-medium flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" />拉取成功
</span>
<span className="text-secondary">{runResult.rows} · {runResult.date}</span>
</div>
)}
{testResult && (
<div className="rounded-card border border-accent/30 bg-accent/[0.04] p-2.5 space-y-1.5">
<div className="flex items-center justify-between text-[10px]">
<span className="text-accent font-medium">测试成功</span>
<span className="text-secondary">{testResult.total_rows} </span>
</div>
{!testResult.has_symbol && (
<div className="text-[10px] text-amber-500">数据缺少 symbol 字段,请配置字段映射</div>
)}
{testResult.preview.length > 0 && (
<pre className="text-[9px] font-mono text-muted bg-elevated rounded px-2 py-1.5 overflow-x-auto max-h-32">
{JSON.stringify(testResult.preview, null, 2)}
</pre>
)}
</div>
)}
{error && (
<div className="text-[10px] text-danger text-center bg-danger/[0.06] rounded-btn py-1.5">
{error}
</div>
)}
</div>
)
}