fix(onboarding): 引导页支持内联配置缺 Key 型插件(如 fuyao)

此前 fuyao 因未配 Key 被自检判为不可用, 向导数据源步骤按「缺依赖」
一律禁用, 文案误导(需安装依赖)且无处可配。

- 区分两类不可用: 缺依赖(真禁用) vs 缺 Key(api_key_env 声明型,
  卡片可点, 显示「需配置 API Key,点击填写」)
- 点击展开内联 Key 表单: 官网申请链接 + 先探后存(POST /plugin-key,
  无效不落盘), 验证成功自动选用该数据源
- 浏览器实测: fuyao 卡片可点、表单呈现/自动聚焦/空值禁用正确,
  无效 Key 返回实探错误且不落盘, 取消正常关闭
This commit is contained in:
shy3130
2026-08-30 19:05:44 +08:00
parent 25c2802d6f
commit a499da95c4
+110 -6
View File
@@ -22,6 +22,7 @@ import {
Database,
Plus,
Puzzle,
KeyRound,
} from 'lucide-react'
import { api } from '@/lib/api'
import { useCapabilities, usePreferences, useSettings } from '@/lib/useSharedQueries'
@@ -298,6 +299,10 @@ function DataSourceStep({ onNext, onBack }: { onNext: () => void; onBack: () =>
const [picked, setPicked] = useState<string | null>(null)
// 是否展开「添加自有数据源」编辑器
const [adding, setAdding] = useState(false)
// 正在内联配置 Key 的插件名 (仅 api_key_env 声明型, 如 fuyao)
const [keyFor, setKeyFor] = useState<string | null>(null)
const [keyInput, setKeyInput] = useState('')
const [keyError, setKeyError] = useState<string | null>(null)
const builtin = sources.data?.builtin ?? []
const plugins = sources.data?.plugins ?? []
@@ -345,6 +350,28 @@ function DataSourceStep({ onNext, onBack }: { onNext: () => void; onBack: () =>
switchProvider.mutate(name)
}
// 内联 Key 配置 (先探后存): 验证通过才落盘 secrets.json, 成功即自动选用该源
const keyPlugin = keyFor ? plugins.find(p => p.name === keyFor) : undefined
const saveKey = useMutation({
mutationFn: (name: string) => api.savePluginKey(name, keyInput.trim()),
onSuccess: (data, name) => {
qc.invalidateQueries({ queryKey: QK.dataSources })
qc.invalidateQueries({ queryKey: QK.capabilities })
if (data.ok) {
setKeyFor(null)
setKeyInput('')
setKeyError(null)
if (data.plugin_available) {
setPicked(name)
switchProvider.mutate(name)
}
} else {
setKeyError(data.error || (data.reason === 'invalid' ? 'Key 验证失败,请检查后重试' : '保存失败,请重试'))
}
},
onError: (e: Error) => setKeyError(`保存失败: ${e.message}`),
})
return (
<div>
<div className="flex items-center gap-2.5">
@@ -369,23 +396,35 @@ function DataSourceStep({ onNext, onBack }: { onNext: () => void; onBack: () =>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2.5">
{items.map(item => {
const isSelected = selected === item.name
const unavailable = item.kind === 'plugin' && !item.available
const plugin = item.kind === 'plugin' ? plugins.find(p => p.name === item.name) : undefined
// 缺 Key 型 (声明了 api_key_env) 不算不可用: 引导页内联填写验证即可启用
const needsKey = item.kind === 'plugin' && !item.available && !!plugin?.api_key_env
const unavailable = item.kind === 'plugin' && !item.available && !needsKey
// 切换中的目标卡片: 圆点位置显示转圈, 其余卡片压暗
const switchingToThis = switchProvider.isPending && switchProvider.variables === item.name
return (
<button
key={item.name}
type="button"
onClick={() => choose(item.name, !unavailable)}
onClick={() => {
if (needsKey) {
setKeyFor(keyFor === item.name ? null : item.name)
setKeyInput('')
setKeyError(null)
return
}
choose(item.name, !unavailable)
}}
disabled={unavailable || switchProvider.isPending}
title={unavailable ? plugin?.status || '依赖未安装' : item.display_name}
title={unavailable ? plugin?.status || '依赖未安装' : needsKey ? '需配置 API Key,点击填写' : item.display_name}
className={`relative text-left rounded-card border px-3.5 py-3 transition-all ${
unavailable
? 'border-border/40 bg-elevated/10 opacity-60 cursor-not-allowed'
: isSelected
? 'border-accent/50 bg-accent/[0.06] ring-1 ring-accent/20 cursor-pointer'
: 'border-border/60 bg-elevated/20 hover:bg-elevated/40 cursor-pointer'
: needsKey
? 'border-warning/30 bg-elevated/20 hover:bg-elevated/40 cursor-pointer'
: isSelected
? 'border-accent/50 bg-accent/[0.06] ring-1 ring-accent/20 cursor-pointer'
: 'border-border/60 bg-elevated/20 hover:bg-elevated/40 cursor-pointer'
} ${switchProvider.isPending && !switchingToThis ? 'opacity-50' : ''}`}
>
<div className="flex items-center gap-2">
@@ -409,6 +448,8 @@ function DataSourceStep({ onNext, onBack }: { onNext: () => void; onBack: () =>
<div className="mt-1.5 flex items-center gap-1 pl-3.5">
{unavailable ? (
<span className="text-[10px] text-muted">, </span>
) : needsKey ? (
<span className="text-[10px] text-warning"> API Key,</span>
) : item.datasets.length > 0 ? (
item.datasets.slice(0, 4).map(ds => (
<span key={ds} className="rounded bg-elevated/60 px-1 py-0.5 text-[10px] text-muted">
@@ -463,6 +504,69 @@ function DataSourceStep({ onNext, onBack }: { onNext: () => void; onBack: () =>
</div>
)}
{/* 内联 Key 配置 (缺 Key 型插件, 如 fuyao): 先探后存, 成功即自动选用 */}
{keyFor && keyPlugin && (
<div className="mt-3 rounded-card border border-warning/30 bg-warning/[0.04] px-3.5 py-3">
<div className="flex items-center gap-2">
<KeyRound className="h-3.5 w-3.5 text-warning" />
<span className="text-xs font-medium text-foreground">
{keyPlugin.display_name.replace(/.*?|\(.*?\)/g, '').trim()} API Key
</span>
<span className="rounded bg-elevated/70 px-1 py-px font-mono text-[9px] text-muted">{keyPlugin.api_key_env}</span>
</div>
<p className="mt-1.5 text-[11px] text-muted leading-relaxed">
{keyPlugin.homepage ? (
<>
{' '}
<a href={keyPlugin.homepage} target="_blank" rel="noreferrer" className="text-accent hover:underline">
</a>
{' '} Key; (secrets.json),
</>
) : (
'向数据源官方申请 Key;仅存本地 (secrets.json),先验证后保存,成功后自动选用该数据源。'
)}
</p>
<form
className="mt-2.5 flex items-center gap-2"
onSubmit={e => {
e.preventDefault()
if (keyInput.trim() && !saveKey.isPending) saveKey.mutate(keyFor)
}}
>
<input
type="password"
value={keyInput}
onChange={e => { setKeyInput(e.target.value); setKeyError(null) }}
placeholder={`粘贴 ${keyPlugin.api_key_env}`}
autoFocus
className="h-8 flex-1 rounded-input border border-border bg-surface px-3 font-mono text-xs text-foreground placeholder:text-muted/60 focus:border-accent/60 focus:outline-none"
/>
<button
type="submit"
disabled={!keyInput.trim() || saveKey.isPending}
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-btn bg-accent px-3 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50 transition-colors"
>
{saveKey.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
{saveKey.isPending ? '验证中…' : '验证并保存'}
</button>
<button
type="button"
onClick={() => { setKeyFor(null); setKeyInput(''); setKeyError(null) }}
className="h-8 shrink-0 rounded-btn px-3 text-xs text-secondary hover:text-foreground hover:bg-elevated transition-colors"
>
</button>
</form>
{keyError && (
<p className="mt-1.5 flex items-center gap-1 text-[11px] text-danger">
<AlertCircle className="h-3 w-3 shrink-0" />
{keyError}
</p>
)}
</div>
)}
{/* 添加自有数据源: 复用设置页编辑器 (命名/鉴权/数据集字段映射/测试/启用) */}
<AnimatePresence mode="wait">
{adding && (