@@ -0,0 +1,326 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user