storage/mod.rs (2957 -> 1834 行): - 抽出 tests.rs: 17 个测试函数 - 抽出 row_mapping.rs: 8 个 row<->record 映射与单记录查询函数 - 抽出 migrations.rs: 7 个 schema 迁移函数 (ensure_*_schema, has_column, add_column_if_missing) ConfigPage.tsx (1568 -> 1235 行): - 抽出 types.ts: 所有接口/类型定义 - 抽出 constants.ts: TABS, inputCls, selectCls, TIMEZONE_OPTIONS - 抽出 ui.tsx: Field, Toggle, TagEditor, SectionCard, SourceEditor, MapEntryHeader - 抽出 api/expert.ts: getSelectedExpert, selectExpert (经 ConfigPage 再导出保持兼容) 验证: cargo build, cargo test --lib storage:: 17/17, npm run build 对抗性检查: 8/8 通过 (无丢失/重复函数, 无循环依赖, 无未使用导入)
170 lines
7.2 KiB
TypeScript
170 lines
7.2 KiB
TypeScript
// Shared UI primitives extracted from ConfigPage.tsx
|
|
import { useState, type ReactNode } from 'react'
|
|
import { X, Plus, Trash2 } from 'lucide-react'
|
|
import { inputCls } from './constants'
|
|
import type { KnownSource } from './types'
|
|
|
|
export function Field({ label, children, hint }: { label: string; children: ReactNode; hint?: string }) {
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<label className="block text-[13px] font-medium text-[var(--text-secondary)]">{label}</label>
|
|
{children}
|
|
{hint && <p className="text-xs text-[var(--text-muted)]">{hint}</p>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => onChange(!checked)}
|
|
className={`relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors duration-200 ${checked ? 'bg-[var(--accent-cyan)]' : 'bg-[var(--bg-hover)]'}`}
|
|
>
|
|
<span className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200 ${checked ? 'translate-x-5' : 'translate-x-0'}`} />
|
|
</button>
|
|
)
|
|
}
|
|
|
|
export function TagEditor({ tags, onChange }: { tags: string[]; onChange: (t: string[]) => void }) {
|
|
const [input, setInput] = useState('')
|
|
const add = () => { const v = input.trim(); if (v && !tags.includes(v)) { onChange([...tags, v]); setInput('') } }
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{tags.map((t, i) => (
|
|
<span key={t} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)]">
|
|
{t}
|
|
<button onClick={() => onChange(tags.filter((_, j) => j !== i))} className="hover:text-white transition-colors"><X className="h-3 w-3" /></button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<input value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), add())} placeholder="输入后按 Enter" className={inputCls + ' !text-xs'} />
|
|
<button onClick={add} className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs">
|
|
<Plus className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function SectionCard({ title, subtitle, children }: { title: string; subtitle?: string; children: ReactNode }) {
|
|
return (
|
|
<div className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden">
|
|
<div className="px-4 py-2.5 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/30">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="text-sm font-medium text-[var(--text-secondary)]">{title}</h3>
|
|
{subtitle && <span className="text-[10px] text-[var(--text-muted)] bg-[var(--bg-tertiary)] px-1.5 py-0.5 rounded">{subtitle}</span>}
|
|
</div>
|
|
</div>
|
|
<div className="p-4 space-y-4">{children}</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function SourceEditor({
|
|
sources,
|
|
onChange,
|
|
knownSources,
|
|
examplePaths,
|
|
showCustom = true,
|
|
}: {
|
|
sources: string[]
|
|
onChange: (s: string[]) => void
|
|
knownSources: KnownSource[]
|
|
examplePaths?: string[]
|
|
showCustom?: boolean
|
|
}) {
|
|
const [customInput, setCustomInput] = useState('')
|
|
const knownKeys = new Set(knownSources.map(k => k.key))
|
|
const customPaths = sources.filter(s => !knownKeys.has(s))
|
|
|
|
const toggleKnown = (key: string) => {
|
|
if (sources.includes(key)) {
|
|
onChange(sources.filter(s => s !== key))
|
|
} else {
|
|
onChange([...sources, key])
|
|
}
|
|
}
|
|
|
|
const addCustom = () => {
|
|
const v = customInput.trim()
|
|
if (v && !sources.includes(v)) {
|
|
onChange([...sources, v])
|
|
setCustomInput('')
|
|
}
|
|
}
|
|
|
|
const removeCustom = (path: string) => {
|
|
onChange(sources.filter(s => s !== path))
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Known sources as toggles */}
|
|
<div className="space-y-2">
|
|
{knownSources.map(src => (
|
|
<div key={src.key} className="flex items-center justify-between py-1.5">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm text-[var(--text-primary)]">{src.label}</div>
|
|
<div className="text-xs text-[var(--text-muted)] font-mono">{src.description}</div>
|
|
</div>
|
|
<Toggle checked={sources.includes(src.key)} onChange={() => toggleKnown(src.key)} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Custom paths (only shown when showCustom is true) */}
|
|
{showCustom && (
|
|
<div className="space-y-2">
|
|
<div className="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">自定义路径</div>
|
|
{customPaths.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{customPaths.map((p) => (
|
|
<span key={p} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)] font-mono">
|
|
{p}
|
|
<button onClick={() => removeCustom(p)} className="hover:text-white transition-colors"><X className="h-3 w-3" /></button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex gap-2">
|
|
<input
|
|
value={customInput}
|
|
onChange={e => setCustomInput(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addCustom())}
|
|
placeholder="输入绝对路径,如 D:\my-skills"
|
|
className={inputCls + ' !text-xs font-mono'}
|
|
/>
|
|
<button onClick={addCustom} className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs shrink-0">
|
|
<Plus className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
{examplePaths && (
|
|
<p className="text-xs text-[var(--text-muted)]">
|
|
示例: {examplePaths.join('、')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function MapEntryHeader({ name, onDelete, onRename }: { name: string; onDelete: () => void; onRename?: (n: string) => void }) {
|
|
const [editing, setEditing] = useState(false)
|
|
const [val, setVal] = useState(name)
|
|
return (
|
|
<div className="flex items-center gap-2 px-4 py-2 bg-[var(--bg-tertiary)]/50 border-b border-[var(--border-color)]">
|
|
{editing ? (
|
|
<input value={val} onChange={e => setVal(e.target.value)} onBlur={() => { setEditing(false); onRename?.(val.trim() || name) }} onKeyDown={e => e.key === 'Enter' && (setEditing(false), onRename?.(val.trim() || name))} className={inputCls + ' !py-1 !text-xs max-w-[200px]'} autoFocus />
|
|
) : (
|
|
<span className="text-sm font-mono text-[var(--accent-cyan)] cursor-pointer" onClick={() => onRename && setEditing(true)}>{name}</span>
|
|
)}
|
|
<div className="flex-1" />
|
|
<button onClick={onDelete} className="p-1 rounded text-red-400/60 hover:text-red-400 hover:bg-red-500/10 transition-colors"><Trash2 className="h-3.5 w-3.5" /></button>
|
|
</div>
|
|
)
|
|
}
|