Files
tick-stock-panel/frontend/src/pages/settings/ImportCenter.tsx
T
shy3130 ffe1ff0d3e feat(import): 统一导入备份入口并支持后端扩展包
集中处理策略与定义、配置备份、数据源和页面插件导入,保留旧入口跳转。新增可信后端及前后端完整包的预检、暂存、重启生效、失败回退和版本管理。

验证:扩展、资源、备份与页面定向 pytest 通过,前端 41 项测试及生产构建通过,并完成桌面、窄屏和明暗主题联调。

Signed-off-by: shy3130 <shy3130@qq.com>
2026-11-01 20:01:59 +08:00

370 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useRef, useState, type ReactNode } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, ArrowRight, Check, CheckCircle2, Clock3, Download, FileCode2, FolderOpen, Info, Loader2, Package, Plus, RefreshCw, Server, ShieldAlert, Upload } from 'lucide-react'
import { api, type ImportCenterPreview, type ManagedExtensionPackage, type PluginDataSourceItem, type ResourceKind, type RuntimePagePack } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { cn } from '@/lib/cn'
import { resourceClosure } from '@/lib/resourceTransfer'
import { inspectPageBlob, inspectPageModule, refreshResourceQueries } from '@/lib/importCenter'
import { runtimeBundleUrl } from '@/extensions/runtime'
import { getFrontendExtensionLoadErrors } from '@/extensions/registry'
import { ResourceTransferDialog } from '@/components/ResourceTransferDialog'
import { PackageActions } from '@/custom/source-packages/extension'
import { PluginKeyConfig } from './DataSources'
import { INVALIDATE_KEYS, SettingsBackupPanel } from './Backup'
const BUTTON = 'inline-flex min-h-11 items-center justify-center gap-1.5 rounded-btn border border-border bg-surface px-3 py-2 text-xs font-medium text-secondary transition-colors hover:bg-elevated hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-45 sm:min-h-9'
const PRIMARY = cn(BUTTON, 'border-blue-600 bg-blue-600 text-white hover:bg-blue-700 hover:text-white')
const INPUT = 'min-h-11 w-full min-w-0 rounded-input border border-border bg-base px-3 py-2 text-base text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent sm:min-h-9 sm:text-sm'
const KIND = { strategy: '策略', factor: '因子', signal: '信号', settings: '调参', code: '辅助模块' }
const TYPE = { resources: '策略与定义资源', backup: '配置备份', source: '数据源插件', extension: '后端扩展', page: '页面插件', project: '项目源码页面' }
type Review = ImportCenterPreview | { kind: 'project'; preview: { namespace: string } }
type Result = { title: string; message: string; details: string[]; target?: string; path?: string }
function Notice({ children, warning = false }: { children: ReactNode; warning?: boolean }) {
return <div className={cn('flex items-start gap-2 rounded-btn px-3 py-3 text-xs leading-6', warning ? 'bg-warning/10 text-amber-800 dark:text-amber-300' : 'bg-accent/10 text-blue-800 dark:text-blue-300')}>
<Info className="mt-1 h-3.5 w-3.5 shrink-0" aria-hidden="true" /><div className="min-w-0 break-words">{children}</div>
</div>
}
function Status({ children }: { children: string }) {
return <span className={cn('inline-flex max-w-full rounded px-2 py-1 text-[11px] leading-5',
/失败|损坏/.test(children) ? 'bg-danger/10 text-danger' : /待|重启/.test(children) ? 'bg-warning/10 text-amber-800 dark:text-amber-300' : /已启用|已就绪/.test(children) ? 'bg-emerald-500/10 text-emerald-800 dark:text-emerald-300' : 'bg-elevated text-secondary')}>{children}</span>
}
function Stepper({ step }: { step: number }) {
return <ol className="mb-6 flex flex-wrap items-center gap-x-6 gap-y-3 text-xs text-secondary" aria-label="导入进度">
{['选择文件', '预览确认', '处理结果'].map((label, index) => <li key={label} aria-current={index === step ? 'step' : undefined} className={cn('flex items-center gap-2', index === step && 'text-accent')}>
<span className={cn('flex h-6 w-6 items-center justify-center rounded-full text-[11px]', index < step ? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' : index === step ? 'bg-accent/10' : 'bg-elevated')}>{index < step ? <Check className="h-3.5 w-3.5" aria-hidden="true" /> : index + 1}</span>{label}
</li>)}
</ol>
}
const formatSize = (n: number) => n >= 1024 * 1024 ? (n / 1024 / 1024).toFixed(1) + ' MiB' : Math.max(1, Math.round(n / 1024)) + ' KiB'
const toggle = (previous: Set<string>, key: string) => {
const next = new Set(previous)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
}
export function SettingsImportCenterPanel() {
const [params, setParams] = useSearchParams()
const view = params.get('view') ?? 'import'
const qc = useQueryClient()
const fileInput = useRef<HTMLInputElement>(null)
const busyRef = useRef(false)
const [busy, setBusy] = useState(false)
const [dragging, setDragging] = useState(false)
const [files, setFiles] = useState<File[]>([])
const [review, setReview] = useState<Review | null>(null)
const [result, setResult] = useState<Result | null>(null)
const [error, setError] = useState('')
const [trusted, setTrusted] = useState(false)
const [selected, setSelected] = useState(new Set<string>())
const [overwrite, setOverwrite] = useState(new Set<string>())
const [categories, setCategories] = useState(new Set<string>())
const [allowOverwrite, setAllowOverwrite] = useState(false)
const [pageId, setPageId] = useState('')
const [namespace, setNamespace] = useState('')
const [exportKind, setExportKind] = useState<ResourceKind>(() => {
const kind = params.get('kind')
return kind === 'factor' || kind === 'signal' ? kind : 'strategy'
})
const [exporting, setExporting] = useState(false)
const packages = useQuery({ queryKey: QK.extensionPackages, queryFn: api.extensionPackages, enabled: view === 'installed', staleTime: 0 })
const sources = useQuery({ queryKey: QK.dataSources, queryFn: api.dataSources, enabled: view === 'installed' })
const pages = useQuery({ queryKey: QK.extPages, queryFn: api.extPagesList, enabled: view === 'installed' || review?.kind === 'page' || review?.kind === 'project' })
const namespaces = useQuery({ queryKey: QK.extPagesSourceNamespaces, queryFn: api.extPagesSourceNamespaces, enabled: view === 'import' && !review && !result })
const matrix = useQuery({ queryKey: QK.capabilityMatrix, queryFn: api.capabilityMatrix, enabled: review?.kind === 'source' })
const entries = review?.kind === 'resources' ? review.preview.entries : []
const effective = useMemo(() => resourceClosure(entries, selected), [entries, selected])
const included = entries.filter(entry => effective.has(entry.key))
const conflicts = included.filter(entry => entry.action === 'conflict')
const issues = included.flatMap(entry => entry.issues.map(issue => entry.label + ':' + issue))
const unresolved = conflicts.some(entry => !overwrite.has(entry.key))
const codeTrust = !!review && (['extension', 'source', 'page', 'project'].includes(review.kind) || included.some(entry => entry.kind === 'strategy' || entry.kind === 'code'))
const packageErrors = review?.kind === 'extension' || review?.kind === 'source' ? review.preview.errors : []
const isPage = review?.kind === 'page' || review?.kind === 'project'
const invalidPageId = isPage && !/^[a-zA-Z0-9_]{1,64}$/.test(pageId)
const pageConflict = isPage && (pages.data ?? []).some(page => page.id === pageId)
const disabled = busy || !!packageErrors.length || !!issues.length || unresolved || (codeTrust && !trusted)
|| (review?.kind === 'resources' && !included.length) || (review?.kind === 'backup' && (!categories.size || !trusted))
|| !!invalidPageId || (isPage && (!pages.isSuccess || (pageConflict && !allowOverwrite)))
function changeView(next: string, target?: string) {
if (busyRef.current) return
const nextParams = new URLSearchParams(params)
nextParams.set('tab', 'import')
if (next === 'import') nextParams.delete('view')
else nextParams.set('view', next)
if (target) nextParams.set('selected', target)
else nextParams.delete('selected')
setParams(nextParams, { replace: true })
setError('')
}
function reset() {
if (busyRef.current) return
setReview(null); setResult(null); setFiles([]); setError(''); setTrusted(false)
setSelected(new Set()); setOverwrite(new Set()); setCategories(new Set()); setAllowOverwrite(false)
changeView('import')
}
async function inspect(candidateFiles: File[]) {
if (busyRef.current || !candidateFiles.length) return
busyRef.current = true; setBusy(true); setError(''); setReview(null); setResult(null); setTrusted(false)
setSelected(new Set()); setOverwrite(new Set()); setCategories(new Set()); setAllowOverwrite(false)
setFiles(candidateFiles)
try {
const next = await api.importCenterInspect(candidateFiles)
setReview(next)
if (next.kind === 'resources') {
const kind = params.get('kind')
const candidates = next.preview.entries.filter(entry => !kind || entry.kind === kind).map(entry => entry.key)
const preferred = next.preview.roots.filter(key => candidates.includes(key))
setSelected(new Set(preferred.length ? preferred : candidates.length ? candidates : next.preview.roots))
}
if (next.kind === 'backup') setCategories(new Set(next.preview.categories.filter(c => c.key !== 'secrets' && c.key !== 'auth').map(c => c.key)))
if (next.kind === 'page') setPageId(next.preview.suggested_id)
} catch (e) { setError(e instanceof Error ? e.message : '文件识别失败,请检查文件格式后重试') }
finally { busyRef.current = false; setBusy(false) }
}
async function refreshInstalled() {
await Promise.allSettled([QK.extensionPackages, QK.extPages, QK.dataSources, QK.capabilityMatrix].map(queryKey => qc.invalidateQueries({ queryKey })))
}
async function apply() {
if (!review || disabled || busyRef.current) return
busyRef.current = true; setBusy(true); setError('')
try {
let next: Result
if (review.kind === 'extension') {
const preview = review.preview
if (preview.manifest.frontend) await inspectPageBlob(await api.extensionPackageFrontend(files[0], preview.digest), preview.manifest.id)
const applied = await api.installExtensionPackage(files[0], preview.digest, preview.revision)
next = { title: '扩展包已保存', message: applied.message, target: 'extension:' + applied.id, details: [
preview.installed_version ? '当前进程继续使用 ' + preview.installed_version + ',重启主程序后切换到 ' + preview.manifest.version + '。' : '扩展尚未启用;后台接口和启动任务在重启主程序后生效。',
...(preview.manifest.frontend ? ['配套页面已通过加载契约检查;等待后台启动成功后,随同一版本启用。'] : []),
'新版本后台启动失败时保留原版本;可在“已安装扩展”查看状态或回退。',
] }
} else if (review.kind === 'source') {
const applied = await api.installSourcePackage(files[0], review.preview.digest, review.preview.revision)
next = { title: '数据源插件已处理', message: applied.message, target: 'source:' + applied.name, details: ['安装不会自动接管取数。请继续配置 API Key,再明确选择启用的数据能力。'] }
} else if (review.kind === 'backup') {
const applied = await api.configBackupApply(files[0], allowOverwrite, [...categories])
const failed = applied.categories.flatMap(c => c.failed.map(f => c.label + ' · ' + f.file + ':' + f.error))
next = { title: failed.length ? '配置恢复完成,部分条目失败' : '所选配置恢复完成', message: '成功恢复 ' + applied.categories.reduce((sum, c) => sum + c.imported, 0) + ' 项,跳过同名项 ' + applied.categories.reduce((sum, c) => sum + c.skipped_conflict, 0) + ' 项。', details: failed.length ? failed : ['只应用已勾选的类别;未选类别保持原样。', ...(categories.has('auth') ? ['访问凭证已恢复,可能需要使用备份中的密码重新登录。'] : [])] }
await Promise.allSettled(INVALIDATE_KEYS.map(queryKey => qc.invalidateQueries({ queryKey })))
await refreshResourceQueries(qc)
} else if (review.kind === 'resources') {
const applied = await api.resourcesApply(files, [...selected], conflicts.filter(entry => overwrite.has(entry.key)).map(entry => entry.key), review.preview.revision)
next = { title: '资源已导入', message: '新增或替换 ' + applied.imported + ' 个文件,复用 ' + applied.reused + ' 个已有文件。', details: [
...(['strategy', 'signal', 'factor'] as const).filter(kind => applied.counts[kind]).map(kind => KIND[kind] + ' ' + applied.counts[kind] + ' 项'),
...(applied.drafts?.length ? [applied.drafts.length + ' 条策略保留为草稿,请在策略库中检查后发布。'] : []),
], path: included.some(e => e.kind === 'strategy') ? '/screener' : included.some(e => e.kind === 'factor') ? '/factors' : '/signals' }
await refreshResourceQueries(qc)
} else {
if (review.kind === 'page' && !review.preview.source) await inspectPageBlob(files[0])
const meta = review.kind === 'project'
? await api.importCenterProjectPage(review.preview.namespace, pageId, allowOverwrite)
: await api.importCenterInstallPage(files[0], pageId, review.preview.digest, allowOverwrite)
let summary = { label: '', path: '' }
try {
summary = await inspectPageModule(runtimeBundleUrl(meta.id) + '?trial=' + Date.now())
await api.extPagesPutManifest(meta.id, { ...summary, error: '' })
} catch (e) {
const message = e instanceof Error ? e.message : '页面加载校验失败'
await api.extPagesPutManifest(meta.id, { ...summary, error: message })
throw new Error(message + '。页面已保持停用,请修正后重新导入。')
}
next = { title: '页面已通过加载校验', message: '刷新浏览器后登记页面和菜单入口。', details: ['页面只使用主程序已有接口。如需配套后台,请导入前后端完整扩展包。'], target: 'page:' + meta.id }
}
setResult(next)
await refreshInstalled()
} catch (e) { setError(e instanceof Error ? e.message : '处理失败,请检查后重试') }
finally { busyRef.current = false; setBusy(false) }
}
const packageDoc = review?.kind === 'source' || review?.kind === 'extension' ? review.preview.manifest : null
const installedVersion = review?.kind === 'source' || review?.kind === 'extension' ? review.preview.installed_version : null
const pendingVersion = review?.kind === 'source' || review?.kind === 'extension' ? review.preview.pending_version : null
const name = review?.kind === 'extension' ? review.preview.manifest.display_name || review.preview.manifest.id
: review?.kind === 'source' ? review.preview.manifest.display_name || review.preview.manifest.name
: review?.kind === 'project' ? review.preview.namespace : review?.kind === 'backup' ? '恢复配置备份' : review?.kind === 'resources' ? '策略、信号与因子' : '导入页面插件'
const selectedItem = params.get('selected') ?? ''
const liveErrors = getFrontendExtensionLoadErrors()
return <div className="max-w-5xl space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div><h2 className="text-xl font-semibold tracking-tight text-foreground">导入与备份</h2><p className="mt-2 text-sm leading-6 text-secondary">一个入口,导入资源、安装扩展、迁移配置。</p></div>
<button type="button" className={BUTTON} disabled={busy} onClick={() => changeView('backup')}><Download className="h-4 w-4" aria-hidden="true" />创建配置备份</button>
</header>
<nav className="flex gap-6 border-b border-border" aria-label="导入与扩展">
{[['import', '导入文件'], ['installed', '已安装扩展']].map(([key, label]) => <button key={key} type="button" aria-pressed={key === 'import' ? view !== 'installed' : view === key} disabled={busy} onClick={() => changeView(key)}
className={cn('min-h-11 border-b-2 pb-3 text-sm font-medium disabled:opacity-50', (key === 'import' ? view !== 'installed' : view === key) ? 'border-accent text-accent' : 'border-transparent text-secondary hover:text-foreground')}>{label}</button>)}
</nav>
{view === 'backup' ? <><SettingsBackupPanel mode="export" /><button type="button" className={BUTTON} onClick={() => changeView('import')}><ArrowLeft className="h-3.5 w-3.5" />返回导入</button></>
: view === 'installed' ? <InstalledView packages={packages.data ?? []} sources={(sources.data?.plugins ?? []).filter(p => p.origin === 'external')} pages={(pages.data ?? []).filter(p => !p.managed_by)}
loading={packages.isLoading || sources.isLoading || pages.isLoading} error={packages.error || sources.error || pages.error}
selected={selectedItem} select={target => changeView('installed', target)} install={reset} refresh={refreshInstalled} bootErrors={liveErrors} />
: <>
<Stepper step={result ? 2 : review ? 1 : 0} />
{result ? <section className="max-w-2xl py-2" role="status">
<CheckCircle2 className="mb-4 h-9 w-9 text-emerald-700 dark:text-emerald-300" aria-hidden="true" />
<h3 className="text-lg font-semibold text-foreground">{result.title}</h3><p className="mt-3 text-sm leading-7 text-secondary">{result.message}</p>
<ul className="my-6 space-y-3 border-y border-border py-5 text-sm leading-6 text-secondary">{result.details.map((detail, i) => <li key={i} className="break-words">{detail}</li>)}</ul>
<div className="flex flex-wrap gap-3">{result.target && <button type="button" className={PRIMARY} onClick={() => changeView('installed', result.target)}>查看已安装扩展<ArrowRight className="h-3.5 w-3.5" /></button>}
{result.path && <Link to={result.path} className={PRIMARY}>查看导入资源<ArrowRight className="h-3.5 w-3.5" /></Link>}
<button type="button" className={BUTTON} onClick={reset}>继续导入</button></div>
</section> : review ? <>
<header className="space-y-2">
<div className="flex flex-wrap items-center gap-3 text-xs text-secondary"><Package className="h-4 w-4" aria-hidden="true" /><span>{review.kind === 'extension' && review.preview.manifest.frontend ? '完整功能包' : TYPE[review.kind]}</span><span className="break-all">{review.kind === 'project' ? '项目源码 · ' + review.preview.namespace : files.map(f => f.name).join('、')}</span>{!!files.length && <span>{formatSize(files.reduce((sum, f) => sum + f.size, 0))}</span>}</div>
<h3 className="text-lg font-semibold text-foreground">{name}</h3>
{packageDoc && <p className="text-xs text-secondary">{installedVersion ? '本机 ' + installedVersion + ' → 目标 ' + packageDoc.version : '版本 ' + packageDoc.version}{packageDoc.author ? ' · 来源声明:' + packageDoc.author : ''}</p>}
{packageDoc?.description && <p className="max-w-2xl text-sm leading-6 text-secondary">{packageDoc.description}</p>}
{pendingVersion && packageDoc && <Notice warning>已有待生效版本 {pendingVersion}。{pendingVersion === packageDoc.version ? '本次是同一个暂存版本。' : '确认后会用 ' + packageDoc.version + ' 替换该暂存版本,当前运行版本保持不变。'}</Notice>}
</header>
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_16rem]">
<div className="min-w-0 space-y-7">
{review.kind === 'resources' && <section>
<h4 className="mb-3 text-sm font-semibold text-foreground">选择资源与检查依赖</h4>
<div className="divide-y divide-border border-y border-border">{entries.filter(entry => ['strategy', 'signal', 'factor'].includes(entry.kind)).map(entry => <label key={entry.key} className="flex items-start gap-3 py-3 text-sm">
<input className="mt-1 accent-accent" type="checkbox" checked={selected.has(entry.key)} disabled={busy || (effective.has(entry.key) && !selected.has(entry.key))} onChange={() => setSelected(previous => toggle(previous, entry.key))} aria-label={'选择' + entry.label} />
<span className="min-w-0 flex-1 break-words"><span className="font-medium text-foreground">{entry.label}</span><span className="mt-1 block text-xs text-secondary">{KIND[entry.kind]} · {entry.id}{effective.has(entry.key) && !selected.has(entry.key) ? ' · 随所选资源自动携带' : ''}</span></span>
<span className="text-xs text-secondary">{entry.action === 'reuse' ? '复用' : entry.action === 'conflict' ? '内容不同' : '新增'}</span>
</label>)}</div>
{!!conflicts.length && <div className="mt-4 space-y-3"><Notice warning>所选资源或依赖与本机内容不同。确认替换,或取消引用它的主资源后继续。</Notice>{conflicts.map(entry => <div key={entry.key} className="space-y-2 text-xs leading-6 text-secondary">
<label className="flex items-start gap-2"><input className="mt-1 accent-accent" type="checkbox" disabled={busy} checked={overwrite.has(entry.key)} onChange={() => setOverwrite(previous => toggle(previous, entry.key))} aria-label={'确认替换' + entry.label} /><span>用包内版本替换“{entry.label}”</span></label>
{!!entry.referenced_by?.length && <p className="break-words">本机引用方:{entry.referenced_by.join('、')}</p>}
{entry.comparison && <details><summary className="cursor-pointer text-accent">对比本机与包内内容</summary><div className="mt-2 grid gap-3 sm:grid-cols-2">{(['local', 'incoming'] as const).map(key => <div key={key}><p>{key === 'local' ? '本机版本' : '包内版本'}</p><pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap break-all rounded-input bg-elevated p-3">{entry.comparison![key]}</pre></div>)}</div></details>}
</div>)}</div>}
<p className="mt-4 text-xs leading-6 text-secondary">所选 {included.length} 项文件,包含关联定义、策略调参及辅助模块。相同内容自动复用。</p>
</section>}
{review.kind === 'backup' && <section>
<h4 className="mb-3 text-sm font-semibold text-foreground">选择恢复范围</h4>
<p className="mb-3 text-xs leading-6 text-secondary">备份时间 {review.preview.created_at || '未知'} · 来源版本 {review.preview.app_version || '未知'}</p>
<div className="divide-y divide-border border-y border-border">{review.preview.categories.map(category => <label key={category.key} className="flex items-start gap-3 py-3 text-sm">
<input className="mt-1 accent-accent" type="checkbox" disabled={busy} checked={categories.has(category.key)} onChange={() => setCategories(previous => toggle(previous, category.key))} aria-label={'恢复' + category.label} />
<span className="min-w-0 flex-1"><span className="text-foreground">{category.label}</span><span className="mt-1 block text-xs text-secondary">{category.count} 项{category.conflicts ? ' · ' + category.conflicts + ' 项同名' : ''}</span></span>
{['secrets', 'auth'].includes(category.key) && <span className="text-xs text-amber-800 dark:text-amber-300">敏感项</span>}
</label>)}</div>
<div className="mt-4"><Notice warning>密钥(API Key)和访问凭证需单独勾选。偏好与密钥按合并写入;恢复敏感项会覆盖本机同名值。</Notice></div>
{!!review.preview.conflict_files && <label className="mt-4 flex items-start gap-2 text-xs leading-6 text-secondary"><input type="checkbox" checked={allowOverwrite} disabled={busy} onChange={e => setAllowOverwrite(e.target.checked)} className="mt-1 accent-accent" />允许覆盖已存在的同名文件(默认跳过)</label>}
{!!review.preview.out_of_scope_files && <p className="mt-3 text-xs text-secondary">{review.preview.out_of_scope_files} 个不受支持的文件不会恢复。</p>}
</section>}
{review.kind === 'extension' && <section className="space-y-4">
<h4 className="text-sm font-semibold text-foreground">本次变更</h4>
<div className="flex items-start gap-3 border-b border-border pb-4"><Server className="mt-1 h-4 w-4 shrink-0 text-secondary" /><div><p className="text-sm font-medium text-foreground">后端服务与启动任务</p><p className="mt-1 text-xs leading-6 text-secondary">重启后注册扩展接口,执行包内声明的初始化任务。</p></div></div>
{review.preview.manifest.frontend && <div className="flex items-start gap-3 border-b border-border pb-4"><FileCode2 className="mt-1 h-4 w-4 shrink-0 text-secondary" /><div><p className="text-sm font-medium text-foreground">配套前端页面</p><p className="mt-1 text-xs leading-6 text-secondary">与后端按同一版本更新、启停和卸载。</p></div></div>}
<Notice>{review.preview.manifest.frontend ? '后台启动成功后,新版本页面才会对外提供。确认安装时会先加载页面代码检查其前端契约。' : '新版本仅在主程序重启时加载;安装和预览阶段不会执行包内 Python 代码。'}</Notice>
<details className="text-xs leading-6 text-secondary"><summary className="cursor-pointer">查看包内文件与技术信息</summary><p className="mt-2">接口命名空间:/api/extensions/{review.preview.manifest.id}/</p><ul className="mt-2 break-all font-mono">{review.preview.files.map(file => <li key={file}>{file}</li>)}</ul></details>
</section>}
{review.kind === 'source' && <section className="space-y-4"><h4 className="text-sm font-semibold text-foreground">本次变更</h4><p className="text-sm leading-7 text-secondary">安装数据源卡片。支持能力:{review.preview.manifest.datasets.map(id => matrix.data?.capabilities.find(c => c.id === id)?.label || id).join('、')}。</p><Notice>安装后继续配置 API Key 和启用能力。新版本更新在重启后切换,安装不会自动更改当前行情来源。</Notice></section>}
{isPage && <section className="space-y-4"><h4 className="text-sm font-semibold text-foreground">页面与菜单入口</h4><label className="block space-y-2 text-xs text-secondary"><span>页面标识</span><input aria-label="页面标识" value={pageId} disabled={busy} onChange={e => { setPageId(e.target.value); setAllowOverwrite(false) }} className={INPUT} /></label>
{invalidPageId && <p role="alert" className="text-xs text-danger">标识需为 1–64 位字母、数字或下划线。</p>}
{pageConflict && <label className="flex items-start gap-2 text-xs leading-6 text-secondary"><input className="mt-1 accent-accent" type="checkbox" disabled={busy} checked={allowOverwrite} onChange={e => setAllowOverwrite(e.target.checked)} />确认替换该标识现有的页面插件</label>}
{pages.error && <p role="alert" className="text-xs text-danger">本机页面清单读取失败。<button className="ml-2 underline" onClick={() => void pages.refetch()}>重新读取</button></p>}
<Notice>这里只导入前端页面,加载校验通过后刷新生效。需要新的后台接口时,请使用包含 extension.yaml 的完整功能包。</Notice>
</section>}
{(review.kind === 'source' || review.kind === 'extension') && <section className="space-y-3"><h4 className="text-sm font-semibold text-foreground">兼容检查</h4>{packageErrors.length ? <div role="alert" className="space-y-2 text-sm text-danger">{packageErrors.map((message, i) => <p key={i}>{message}</p>)}</div> : <p className="flex items-center gap-2 text-sm text-emerald-800 dark:text-emerald-300"><CheckCircle2 className="h-4 w-4" />静态兼容检查通过</p>}<p className="text-xs leading-6 text-secondary">缺少依赖时停止安装,不会自动修改主程序运行环境。</p></section>}
{!!issues.length && <div role="alert" className="space-y-2 text-xs leading-6 text-danger">{issues.map((issue, i) => <p key={i}>{issue}</p>)}</div>}
</div>
<aside className="min-w-0 rounded-card border border-border bg-surface p-4">
<h4 className="text-sm font-semibold text-foreground">{review.kind === 'backup' ? '本次恢复' : '本次操作'}</h4>
<dl className="my-4 space-y-3 text-xs leading-6"><div className="flex justify-between gap-3"><dt className="text-secondary">处理方式</dt><dd className="text-right text-foreground">{review.kind === 'backup' ? '按类别恢复' : review.kind === 'resources' ? '所选资源与依赖' : 'installed_version' in review.preview && review.preview.installed_version ? '更新已有扩展' : '新增导入'}</dd></div>
<div className="flex justify-between gap-3"><dt className="text-secondary">生效时间</dt><dd className="text-right text-foreground">{review.kind === 'extension' ? '重启主程序后' : isPage ? '刷新页面后' : review.kind === 'source' && review.preview.installed_version ? '重启主程序后' : '处理完成后'}</dd></div>
{review.kind === 'backup' && <div className="flex justify-between gap-3"><dt className="text-secondary">恢复范围</dt><dd>{categories.size} / {review.preview.categories.length} 类</dd></div>}
{packageDoc && <div className="flex justify-between gap-3"><dt className="text-secondary">目标版本</dt><dd>{packageDoc.version}</dd></div>}
</dl>
{(codeTrust || review.kind === 'backup') && <label className="flex items-start gap-2 text-xs leading-6 text-secondary"><input type="checkbox" className="mt-1 accent-accent" disabled={busy} aria-label={review.kind === 'backup' ? '信任备份来源' : '信任代码来源'} checked={trusted} onChange={e => setTrusted(e.target.checked)} /><span>{review.kind === 'backup' ? '我确认这是可信的备份,允许恢复所选配置及其中的代码。' : '我信任此代码来源,允许它在本机运行。代码可访问主程序的数据和凭据。'}</span></label>}
<button type="button" className={cn(PRIMARY, 'mt-4 w-full whitespace-normal')} disabled={disabled} onClick={() => void apply()}>{busy && <Loader2 className="h-4 w-4 animate-spin" />}{busy ? '正在处理…' : review.kind === 'backup' ? '恢复所选配置' : review.kind === 'resources' ? '确认导入' : 'installed_version' in review.preview && review.preview.installed_version ? '确认更新' : '确认安装'}</button>
<p className="mt-3 text-xs leading-6 text-secondary">确认前不会应用任何变更。{unresolved ? '请先处理同名冲突。' : ''}</p>
</aside>
</div>
<button type="button" className={BUTTON} disabled={busy} onClick={reset}><ArrowLeft className="h-3.5 w-3.5" />重新选择文件</button>
</> : <>
<div className={cn('rounded-card border border-dashed bg-surface px-4 py-10 text-center transition-colors sm:py-12', dragging ? 'border-accent bg-accent/5' : 'border-border')}
onDragOver={e => { e.preventDefault(); if (!busy) setDragging(true) }} onDragLeave={() => setDragging(false)} onDrop={e => { e.preventDefault(); setDragging(false); void inspect(Array.from(e.dataTransfer.files)) }}>
<Upload className="mx-auto mb-4 h-7 w-7 text-secondary" aria-hidden="true" /><h3 className="text-lg font-semibold text-foreground">把文件放到这里</h3>
<p className="mx-auto mt-3 max-w-xl text-sm leading-7 text-secondary">自动识别策略与定义、配置备份、数据源插件、页面插件,以及后端或前后端完整扩展包。</p>
<button type="button" className={cn(PRIMARY, 'mt-5')} disabled={busy} onClick={() => fileInput.current?.click()}>{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <FolderOpen className="h-4 w-4" />}{busy ? '正在识别文件…' : '选择文件'}</button>
<p className="mt-3 text-xs leading-6 text-secondary">支持 ZIP、PY、JSON、JS、MJS、TSX。资源文件可多选;扩展和备份逐包处理。</p>
</div>
{!!namespaces.data?.length && <details className="border-b border-border pb-5 text-xs text-secondary"><summary className="cursor-pointer">从本项目的源码页面导入</summary><p className="mt-3 leading-6">把现有源码页面编译为独立页面插件;不会把它的后端代码一起打包。</p><div className="mt-3 flex flex-wrap items-center gap-2"><select aria-label="项目源码页面" className={cn(INPUT, 'sm:w-64')} value={namespace} onChange={e => setNamespace(e.target.value)} disabled={busy}><option value="">选择源码页面</option>{namespaces.data.map(ns => <option key={ns} value={ns}>{ns}</option>)}</select><button type="button" className={BUTTON} disabled={busy || !namespace} onClick={() => { setReview({ kind: 'project', preview: { namespace } }); setPageId(namespace.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 64)); setTrusted(false); setAllowOverwrite(false) }}>预览项目页面</button></div></details>}
<section className="flex flex-wrap items-end justify-between gap-4 border-b border-border pb-5">
<div><h3 className="text-sm font-medium text-foreground">分享策略、信号与因子</h3><p className="mt-2 text-xs leading-6 text-secondary">批量导出定义和关联依赖;换机迁移请使用“创建配置备份”。</p></div>
<div className="flex flex-wrap gap-2"><select aria-label="导出资源类型" className={cn(INPUT, 'w-auto')} value={exportKind} onChange={e => setExportKind(e.target.value as ResourceKind)}>{(['strategy', 'signal', 'factor'] as const).map(kind => <option key={kind} value={kind}>{KIND[kind]}</option>)}</select><button type="button" className={BUTTON} onClick={() => setExporting(true)}><Download className="h-3.5 w-3.5" />批量导出资源</button></div>
</section>
<Notice>导入前会展示变更范围、依赖和冲突。后端扩展安装到用户数据目录,升级主程序不会覆盖安装文件。</Notice>
</>}
<input ref={fileInput} type="file" className="hidden" aria-label="选择导入文件" multiple accept=".zip,.py,.json,.js,.mjs,.tsx" disabled={busy} onChange={e => { const next = Array.from(e.target.files ?? []); e.target.value = ''; void inspect(next) }} />
</>}
{error && <div role="alert" className="flex items-start gap-2 rounded-btn border border-danger/30 bg-danger/5 p-3 text-sm leading-6 text-danger"><ShieldAlert className="mt-1 h-4 w-4 shrink-0" /><span className="min-w-0 break-words">{error}</span>{!review && !!files.length && <button type="button" className="shrink-0 underline" disabled={busy} onClick={() => void inspect(files)}>重新预览</button>}</div>}
{exporting && <ResourceTransferDialog kind={exportKind} initialMode="export" exportOnly onClose={() => setExporting(false)} />}
</div>
}
function InstalledView({ packages, sources, pages, selected, select, install, refresh, loading, error, bootErrors }: {
packages: ManagedExtensionPackage[]; sources: PluginDataSourceItem[]; pages: RuntimePagePack[]
selected: string; select: (target: string) => void; install: () => void; refresh: () => Promise<void>
loading: boolean; error: unknown; bootErrors: { source: string; error: string; extensionId?: string }[]
}) {
const [busy, setBusy] = useState(false)
const [failure, setFailure] = useState('')
const [message, setMessage] = useState('')
const [confirm, setConfirm] = useState('')
const pack = packages.find(p => selected === 'extension:' + p.id)
const source = sources.find(p => selected === 'source:' + p.name)
const page = pages.find(p => selected === 'page:' + p.id)
const items = [
...packages.map(p => ({ key: 'extension:' + p.id, title: p.name, subtitle: (p.frontend ? '前端 + 后端' : '后端扩展') + ' · ' + (p.version || '尚未生效') + (p.pending_version ? ' → ' + p.pending_version : ''), status: p.status, icon: Server })),
...sources.map(p => ({ key: 'source:' + p.name, title: p.display_name, subtitle: '数据源插件 · ' + (p.version || '—') + (p.pending_version ? ' → ' + p.pending_version : ''), status: p.pending_remove ? '待重启卸载' : p.update_error ? '更新失败' : p.pending_version ? '待重启更新' : p.status, icon: Package })),
...pages.map(p => ({ key: 'page:' + p.id, title: p.label || p.id, subtitle: '页面插件 · ' + (p.path || '尚未登记页面路径'), status: p.error ? '加载失败' : p.enabled ? '已启用' : '已停用', icon: FileCode2 })),
]
async function run(action: string) {
if (busy) return
setBusy(true); setFailure(''); setMessage('')
try {
if (pack) setMessage((await api.manageExtensionPackage(pack.id, action, pack.revision)).message)
if (page) {
if (action === 'remove') await api.extPagesDelete(page.id)
else await api.extPagesPutEnabled(page.id, action === 'enable')
setMessage('变更已保存,刷新浏览器后生效')
}
setConfirm('')
await refresh()
} catch (e) { setFailure(e instanceof Error ? e.message : '操作失败,请重试'); await refresh() }
finally { setBusy(false) }
}
const activeError = pack?.error || page?.error
return <>
<header className="flex flex-wrap items-start justify-between gap-3"><div><h3 className="text-lg font-semibold text-foreground">已安装扩展</h3><p className="mt-2 text-xs leading-6 text-secondary">统一查看版本、启停状态与待生效变更。</p></div><div className="flex gap-2"><button className={BUTTON} disabled={busy || loading} onClick={() => void refresh()} aria-label="刷新扩展清单"><RefreshCw className="h-3.5 w-3.5" /></button><button className={BUTTON} disabled={busy} onClick={install}><Plus className="h-3.5 w-3.5" />安装扩展</button></div></header>
{!!error && <div role="alert" className="text-sm text-danger">扩展清单读取失败,已保留可用列表。请点击刷新重试。</div>}
{loading && <p role="status" className="flex gap-2 text-sm text-secondary"><Loader2 className="h-4 w-4 animate-spin" />正在读取扩展…</p>}
<div className={cn('grid items-start gap-6', (pack || source || page) && 'xl:grid-cols-[minmax(0,1fr)_19rem]')}>
<div className="min-w-0 divide-y divide-border border-y border-border">
{!loading && !items.length && <p className="py-12 text-sm leading-7 text-secondary">尚未安装外部扩展。可从“导入文件”开始;源码内置页面仍在“页面管理”维护。</p>}
{items.map(item => <button key={item.key} type="button" disabled={busy} aria-pressed={selected === item.key} onClick={() => { setFailure(''); setMessage(''); setConfirm(''); select(item.key) }} className={cn('flex w-full flex-wrap items-center gap-3 py-4 text-left hover:bg-elevated/50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:opacity-50', selected === item.key && 'text-accent')}>
<item.icon className="h-4 w-4 shrink-0 text-secondary" aria-hidden="true" /><span className="min-w-0 flex-1 basis-40"><span className="block break-words text-sm font-medium">{item.title}</span><span className="mt-1 block break-words text-xs leading-6 text-secondary">{item.subtitle}</span></span><Status>{item.status}</Status>
</button>)}
</div>
{(pack || source || page) && <aside className="min-w-0 rounded-card border border-border bg-surface p-4">
<h4 className="break-words text-sm font-semibold text-foreground">{pack?.name || source?.display_name || page?.label || page?.id}</h4>
{pack && <><dl className="my-4 space-y-3 text-xs leading-6"><div className="flex justify-between gap-3"><dt className="text-secondary">当前版本</dt><dd>{pack.version || '尚未生效'}</dd></div>{pack.pending_version && <div className="flex justify-between gap-3"><dt className="text-secondary">待生效版本</dt><dd>{pack.pending_version}</dd></div>}<div className="flex justify-between gap-3"><dt className="text-secondary">当前进程</dt><dd>{pack.runtime_version ? '已加载 ' + pack.runtime_version : '未加载'}</dd></div></dl>
<p className="text-xs leading-6 text-secondary">{pack.frontend ? '前端页面与后台服务一起更新、启停和卸载。' : '后端接口、初始化任务和通知格式化器在重启后生效。'}</p>
{!pack.pending_remove && <div className="mt-4 flex flex-wrap gap-2"><button className={BUTTON} disabled={busy} onClick={() => void run(pack.enabled ? 'disable' : 'enable')}>{pack.enabled ? '停用扩展' : '启用扩展'}</button>{pack.previous_version && <button className={BUTTON} disabled={busy} onClick={() => setConfirm('rollback')}>{'回退到 ' + pack.previous_version}</button>}{pack.pending_version && pack.version && <button className={BUTTON} disabled={busy} onClick={() => void run('cancel-update')}>取消待生效更新</button>}<button className={cn(BUTTON, 'text-danger')} disabled={busy} onClick={() => setConfirm('remove')}>卸载扩展</button></div>}
<p className="mt-4 flex items-start gap-2 text-xs leading-6 text-secondary"><Clock3 className="mt-1 h-3.5 w-3.5 shrink-0" />变更后请重启主程序;刷新浏览器不能替代后台重启。</p>
</>}
{source && <div className="mt-4 space-y-4">{source.api_key_env && <PluginKeyConfig key={source.name} plugin={source} />}<PackageActions key={source.name} plugin={source} refresh={() => { void refresh() }} /></div>}
{page && <><p className="mt-3 break-all text-xs leading-6 text-secondary">页面标识:{page.id}</p><p className="mt-2 text-xs leading-6 text-secondary">启停和卸载在刷新浏览器后生效。更新时从“导入文件”上传新产物,并确认替换相同标识。</p><div className="mt-4 flex flex-wrap gap-2"><button className={BUTTON} disabled={busy || !!page.error} onClick={() => void run(page.enabled ? 'disable' : 'enable')}>{page.enabled ? '停用页面' : '启用页面'}</button><button className={cn(BUTTON, 'text-danger')} disabled={busy} onClick={() => setConfirm('remove')}>卸载页面</button></div></>}
{activeError && <p className="mt-4 break-words text-xs leading-6 text-danger">{pack?.failed_version ? '版本 ' + pack.failed_version + ' 启动失败;' + (pack.runtime_version ? '当前仍运行 ' + pack.runtime_version + '。' : '扩展未启用。') : activeError}</p>}
{confirm && <div className="mt-5 space-y-3 border-t border-border pt-4"><p className="text-xs leading-6 text-secondary">{confirm === 'remove' ? '确认卸载此扩展?扩展包及关联页面将移除,包目录外的数据保留。' : '确认回退:' + pack?.version + ' → ' + pack?.previous_version + '。重启后切换,原有启停状态保留。' + (pack?.pending_version ? '已暂存的 ' + pack.pending_version + ' 将被替换。' : '')}</p><div className="flex flex-wrap gap-2"><button className={BUTTON} disabled={busy} onClick={() => setConfirm('')}>取消</button><button className={cn(BUTTON, confirm === 'remove' && 'text-danger')} disabled={busy} onClick={() => void run(confirm)}>{busy ? '处理中…' : '确认'}</button></div></div>}
</aside>}
</div>
{failure && <p role="alert" className="break-words text-sm text-danger">{failure}</p>}
{message && <p role="status" className="text-sm leading-6 text-secondary">{message}</p>}
{!!bootErrors.filter(e => e.source.startsWith('runtime:')).length && <Notice warning><p className="font-medium">本次启动有页面加载失败</p>{bootErrors.filter(e => e.source.startsWith('runtime:')).map((e, i) => <p key={i} className="break-words">{e.source}:{e.error}</p>)}<p>请停用或更新对应页面,避免与主程序或其他扩展的路由冲突。</p></Notice>}
</>
}