feat: 添加注释
This commit is contained in:
+64
-25
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { AskDeepSeek, GetActiveLibrary, GetDBStatus, StopGeneration } from 'wailsjs/go/main/App';
|
||||
import { AskDeepSeek, DeleteItems, GetActiveLibrary, GetDBStatus, StopGeneration } from 'wailsjs/go/main/App';
|
||||
import { EventsOn } from 'wailsjs/runtime/runtime';
|
||||
import TitleBar from './components/TitleBar';
|
||||
import LibraryBar from './components/LibraryBar';
|
||||
@@ -7,6 +7,8 @@ import SearchInput from './components/SearchInput';
|
||||
import ResultCard from './components/ResultCard';
|
||||
import AIPanel from './components/AIPanel';
|
||||
import SettingsModal from './components/SettingsModal';
|
||||
import ManageModeBar from './components/ManageModeBar';
|
||||
import { Toast, useToast } from './components/Toast';
|
||||
import { useSearch, type SearchResult } from './hooks/useSearch';
|
||||
|
||||
export default function App() {
|
||||
@@ -18,20 +20,26 @@ export default function App() {
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
const [aiTarget, setAiTarget] = useState<SearchResult | null>(null);
|
||||
const [isFallback, setIsFallback] = useState(false);
|
||||
const [managementMode, setMgmtMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const aiTextRef = useRef('');
|
||||
|
||||
const { query, setQuery, results, loading, error } = useSearch();
|
||||
const { query, setQuery, results, setResults, loading, error } = useSearch();
|
||||
const { toasts, showToast } = useToast();
|
||||
|
||||
// DB status + active library polling
|
||||
useEffect(() => {
|
||||
const check = async () => {
|
||||
try { setDbReady(await GetDBStatus()); } catch { setDbReady(false); }
|
||||
};
|
||||
const loadLib = async () => { setActiveLib(await GetActiveLibrary()); };
|
||||
const check = async () => { try { setDbReady(await GetDBStatus()); } catch { setDbReady(false); } };
|
||||
const loadLib = async () => { try { setActiveLib(await GetActiveLibrary()); } catch {} };
|
||||
check(); loadLib();
|
||||
const id = setInterval(check, 5000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Exit management mode when query changes
|
||||
useEffect(() => { setMgmtMode(false); setSelectedIds([]); }, [query]);
|
||||
|
||||
// AI event listeners
|
||||
useEffect(() => {
|
||||
const offChunk = EventsOn('ai:chunk', (c: string) => { aiTextRef.current += c; setAiText(aiTextRef.current); });
|
||||
const offDone = EventsOn('ai:done', () => setAiLoading(false));
|
||||
@@ -44,33 +52,53 @@ export default function App() {
|
||||
setAiTarget(result); setAiText(''); setAiError(null); setIsFallback(false);
|
||||
setAiLoading(true); aiTextRef.current = '';
|
||||
try { await AskDeepSeek(result.question, result.answer); }
|
||||
catch { setAiError('DeepSeek 连接失败'); setAiLoading(false); }
|
||||
catch { setAiError('AI 连接失败'); setAiLoading(false); }
|
||||
}, []);
|
||||
|
||||
const handleStop = useCallback(async () => { await StopGeneration(); }, []);
|
||||
const handleStop = useCallback(async () => { await StopGeneration(); }, []);
|
||||
const closeAI = useCallback(() => {
|
||||
handleStop(); setAiTarget(null); setAiText(''); setAiError(null); setIsFallback(false);
|
||||
}, [handleStop]);
|
||||
|
||||
const handleCopyAI = useCallback(async (text: string) => {
|
||||
try { await navigator.clipboard.writeText(text); }
|
||||
catch {
|
||||
const el = document.createElement('textarea'); el.value = text;
|
||||
document.body.appendChild(el); el.select(); document.execCommand('copy');
|
||||
document.body.removeChild(el);
|
||||
}
|
||||
catch { const el = document.createElement('textarea'); el.value = text; document.body.appendChild(el); el.select(); document.execCommand('copy'); document.body.removeChild(el); }
|
||||
}, []);
|
||||
|
||||
const closeAI = useCallback(() => {
|
||||
handleStop(); setAiTarget(null); setAiText(''); setAiError(null); setIsFallback(false);
|
||||
}, [handleStop]);
|
||||
// Single-item delete
|
||||
const handleDelete = useCallback(async (id: number) => {
|
||||
const err = await DeleteItems([id]);
|
||||
if (err) { showToast(err, 'error'); return; }
|
||||
setResults(prev => prev.filter(r => r.id !== id));
|
||||
showToast('已删除', 'success');
|
||||
}, [setResults, showToast]);
|
||||
|
||||
// Management mode
|
||||
const toggleSelect = useCallback((id: number) => {
|
||||
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
|
||||
}, []);
|
||||
const handleMgmtDeleted = useCallback((ids: number[]) => {
|
||||
if (ids.length === 0) setResults([]);
|
||||
else setResults(prev => prev.filter(r => !ids.includes(r.id)));
|
||||
setSelectedIds([]);
|
||||
if (!ids.length) setMgmtMode(false);
|
||||
}, [setResults]);
|
||||
|
||||
return (
|
||||
<div className="sidebar-root">
|
||||
<div className="orb orb-purple" /><div className="orb orb-blue" />
|
||||
<div className="relative z-10 flex flex-col h-full">
|
||||
<TitleBar dbReady={dbReady} onSettings={() => setShowSettings(true)} />
|
||||
|
||||
<LibraryBar activeName={activeLib} onSwitch={name => { setActiveLib(name); setQuery(''); }} />
|
||||
|
||||
<SearchInput value={query} onChange={setQuery} loading={loading} />
|
||||
<div className="flex items-center gap-2 px-4 pb-2">
|
||||
<div className="flex-1"><SearchInput value={query} onChange={setQuery} loading={loading} /></div>
|
||||
<button onClick={() => { setMgmtMode(m => !m); setSelectedIds([]); }}
|
||||
className={`flex-shrink-0 px-2.5 py-2 rounded-xl text-[11px] font-medium border transition-all
|
||||
${managementMode ? 'bg-accent/25 text-accent-light border-accent/40' : 'bg-white/5 text-white/40 border-white/10 hover:bg-white/10'}`}>
|
||||
✏️ 管理
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mx-4 mb-2 px-3 py-2 rounded-lg bg-red-500/10 border border-red-500/25 text-red-400 text-[11px] flex items-center gap-2">
|
||||
@@ -84,9 +112,8 @@ export default function App() {
|
||||
<span className="text-3xl">🔍</span>
|
||||
<p className="text-[12px] text-white/30">知识库暂无相关答案</p>
|
||||
<button id="btn-ask-direct" onClick={() => handleAsk({ id: 0, question: query, answer: '', category: '', score: 0, is_fallback: false })}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-accent/20 text-accent-light
|
||||
border border-accent/30 hover:bg-accent/35 transition-all text-[12px] font-medium">
|
||||
✨ 直接问 DeepSeek
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-accent/20 text-accent-light border border-accent/30 hover:bg-accent/35 transition-all text-[12px] font-medium">
|
||||
✨ 直接问 AI
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -101,16 +128,28 @@ export default function App() {
|
||||
<span>💡</span><span>未找到精确匹配,展示热门问答供参考</span>
|
||||
</div>
|
||||
)}
|
||||
{results.map(r => <ResultCard key={r.id} result={r} onPolish={handleAsk} />)}
|
||||
{results.map(r => (
|
||||
<ResultCard key={r.id} result={r} onPolish={handleAsk} onDelete={handleDelete}
|
||||
managementMode={managementMode} selected={selectedIds.includes(r.id)} onToggleSelect={toggleSelect} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{aiTarget && (
|
||||
<AIPanel text={aiText} loading={aiLoading} error={aiError}
|
||||
isFallback={isFallback} question={aiTarget.question}
|
||||
onCopy={handleCopyAI} onStop={handleStop} onClose={closeAI} />
|
||||
<AIPanel text={aiText} loading={aiLoading} error={aiError} isFallback={isFallback}
|
||||
question={aiTarget.question} onCopy={handleCopyAI} onStop={handleStop} onClose={closeAI} />
|
||||
)}
|
||||
|
||||
{managementMode && (
|
||||
<ManageModeBar selected={selectedIds} totalCount={results.length}
|
||||
onSelectAll={() => setSelectedIds(results.map(r => r.id))}
|
||||
onDeselectAll={() => setSelectedIds([])}
|
||||
onExit={() => { setMgmtMode(false); setSelectedIds([]); }}
|
||||
onDeleted={handleMgmtDeleted} showToast={showToast} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSettings && <SettingsModal onClose={() => setShowSettings(false)} />}
|
||||
<Toast toasts={toasts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { CreateLibrary, DeleteLibrary, ImportCSV, ListLibraries, SwitchLibrary } from 'wailsjs/go/main/App';
|
||||
import { CreateLibrary, DeleteLibrary, ImportCSV, ImportExcel, ListLibraries, SwitchLibrary } from 'wailsjs/go/main/App';
|
||||
import type { handler } from 'wailsjs/go/models';
|
||||
|
||||
interface LibraryBarProps {
|
||||
activeName: string;
|
||||
onSwitch: (name: string) => void;
|
||||
onManageMode?: () => void;
|
||||
}
|
||||
|
||||
export default function LibraryBar({ activeName, onSwitch }: LibraryBarProps) {
|
||||
@@ -44,12 +45,21 @@ export default function LibraryBar({ activeName, onSwitch }: LibraryBarProps) {
|
||||
else setMsg(err);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
const handleImportCSV = async () => {
|
||||
setImporting(true); setMsg('');
|
||||
const result = await ImportCSV();
|
||||
setImporting(false);
|
||||
if (result.error && result.error !== '已取消') setMsg(result.error);
|
||||
else if (result.imported > 0) { setMsg(`✓ 导入了 ${result.imported} 条(跳过 ${result.skipped} 条)`); load(); }
|
||||
else if (result.imported > 0) { setMsg(`✓ CSV 导入了 ${result.imported} 条`); load(); }
|
||||
setTimeout(() => setMsg(''), 4000);
|
||||
};
|
||||
|
||||
const handleImportExcel = async () => {
|
||||
setImporting(true); setMsg('');
|
||||
const result = await ImportExcel();
|
||||
setImporting(false);
|
||||
if (result.error && result.error !== '已取消') setMsg(result.error);
|
||||
else if (result.imported > 0) { setMsg(`✓ Excel 导入了 ${result.imported} 条`); load(); }
|
||||
setTimeout(() => setMsg(''), 4000);
|
||||
};
|
||||
|
||||
@@ -64,10 +74,15 @@ export default function LibraryBar({ activeName, onSwitch }: LibraryBarProps) {
|
||||
<span className="text-[12px] text-white/80 truncate flex-1">{activeName || '选择知识库'}</span>
|
||||
<span className="text-[10px] text-white/30">{open ? '▲' : '▾'}</span>
|
||||
</button>
|
||||
<button onClick={handleImport} disabled={importing} title="导入 CSV"
|
||||
className="px-2.5 py-1.5 rounded-xl bg-accent/15 text-accent-light border border-accent/25
|
||||
hover:bg-accent/25 transition-all text-[11px] font-medium disabled:opacity-50">
|
||||
{importing ? '…' : '📥 导入'}
|
||||
<button onClick={handleImportCSV} disabled={importing} title="导入 CSV"
|
||||
className="px-2 py-1.5 rounded-xl bg-accent/15 text-accent-light border border-accent/25
|
||||
hover:bg-accent/25 transition-all text-[10px] font-medium disabled:opacity-50 whitespace-nowrap">
|
||||
{importing ? '…' : '📥 CSV'}
|
||||
</button>
|
||||
<button onClick={handleImportExcel} disabled={importing} title="导入 Excel"
|
||||
className="px-2 py-1.5 rounded-xl bg-emerald-500/15 text-emerald-300 border border-emerald-500/25
|
||||
hover:bg-emerald-500/25 transition-all text-[10px] font-medium disabled:opacity-50 whitespace-nowrap">
|
||||
{importing ? '…' : '📊 Excel'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ClearDatabase, DeleteItems } from 'wailsjs/go/main/App';
|
||||
|
||||
interface ManageModeBarProps {
|
||||
selected: number[];
|
||||
totalCount: number;
|
||||
onSelectAll: () => void;
|
||||
onDeselectAll: () => void;
|
||||
onExit: () => void;
|
||||
onDeleted: (ids: number[]) => void;
|
||||
showToast: (msg: string, type: 'success' | 'error' | 'info') => void;
|
||||
}
|
||||
|
||||
export default function ManageModeBar({
|
||||
selected, totalCount, onSelectAll, onDeselectAll, onExit, onDeleted, showToast,
|
||||
}: ManageModeBarProps) {
|
||||
const allSelected = selected.length === totalCount && totalCount > 0;
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selected.length === 0) return;
|
||||
const err = await DeleteItems(selected as unknown as number[]);
|
||||
if (err) { showToast(err, 'error'); return; }
|
||||
showToast(`已删除 ${selected.length} 条`, 'success');
|
||||
onDeleted(selected);
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (!window.confirm(`确定要清空当前知识库的所有 ${totalCount} 条记录吗?此操作不可撤销。`)) return;
|
||||
const err = await ClearDatabase();
|
||||
if (err) { showToast(err, 'error'); return; }
|
||||
showToast('已清空知识库', 'success');
|
||||
onDeleted([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-white/8 bg-[rgba(8,6,20,0.95)] px-4 py-2.5 flex items-center gap-2 flex-shrink-0">
|
||||
{/* Left: count + select toggle */}
|
||||
<button onClick={allSelected ? onDeselectAll : onSelectAll}
|
||||
className="flex items-center gap-1.5 text-[11px] text-white/50 hover:text-white/70 transition-colors">
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center transition-colors
|
||||
${allSelected ? 'bg-accent border-accent' : 'border-white/30'}`}>
|
||||
{allSelected && <span className="text-white text-[10px]">✓</span>}
|
||||
</div>
|
||||
{selected.length > 0 ? `已选 ${selected.length}/${totalCount}` : '全选'}
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Clear all */}
|
||||
<button onClick={handleClearAll}
|
||||
className="text-[11px] px-3 py-1.5 rounded-lg text-red-400/60 hover:text-red-400
|
||||
hover:bg-red-500/10 border border-transparent hover:border-red-500/20 transition-all">
|
||||
🗑 清空
|
||||
</button>
|
||||
|
||||
{/* Batch delete */}
|
||||
<button onClick={handleBatchDelete} disabled={selected.length === 0}
|
||||
className="text-[11px] px-3 py-1.5 rounded-lg bg-red-500/20 text-red-300 border border-red-500/30
|
||||
hover:bg-red-500/35 transition-all disabled:opacity-30 disabled:cursor-not-allowed font-medium">
|
||||
删除选中 ({selected.length})
|
||||
</button>
|
||||
|
||||
{/* Exit */}
|
||||
<button onClick={onExit}
|
||||
className="text-[11px] px-3 py-1.5 rounded-lg bg-white/5 text-white/50 border border-white/10
|
||||
hover:bg-white/10 transition-all">
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,90 +4,126 @@ import type { SearchResult } from '../hooks/useSearch';
|
||||
interface ResultCardProps {
|
||||
result: SearchResult;
|
||||
onPolish: (result: SearchResult) => void;
|
||||
onDelete: (id: number) => void;
|
||||
managementMode: boolean;
|
||||
selected: boolean;
|
||||
onToggleSelect: (id: number) => void;
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
'通用': 'bg-indigo-500/20 text-indigo-300',
|
||||
'退款': 'bg-red-500/20 text-red-300',
|
||||
'物流': 'bg-amber-500/20 text-amber-300',
|
||||
'产品': 'bg-emerald-500/20 text-emerald-300',
|
||||
'促销': 'bg-pink-500/20 text-pink-300',
|
||||
'通用': 'bg-indigo-500/20 text-indigo-300',
|
||||
'退款': 'bg-red-500/20 text-red-300',
|
||||
'物流': 'bg-amber-500/20 text-amber-300',
|
||||
'产品': 'bg-emerald-500/20 text-emerald-300',
|
||||
'促销': 'bg-pink-500/20 text-pink-300',
|
||||
'浇水': 'bg-blue-500/20 text-blue-300',
|
||||
'施肥': 'bg-lime-500/20 text-lime-300',
|
||||
'病虫害': 'bg-orange-500/20 text-orange-300',
|
||||
'繁殖': 'bg-purple-500/20 text-purple-300',
|
||||
'换盆': 'bg-teal-500/20 text-teal-300',
|
||||
'修剪': 'bg-cyan-500/20 text-cyan-300',
|
||||
'光照': 'bg-yellow-500/20 text-yellow-300',
|
||||
'季节养护': 'bg-rose-500/20 text-rose-300',
|
||||
'土壤': 'bg-stone-500/20 text-stone-300',
|
||||
};
|
||||
|
||||
export default function ResultCard({ result, onPolish }: ResultCardProps) {
|
||||
export default function ResultCard({ result, onPolish, onDelete, managementMode, selected, onToggleSelect }: ResultCardProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(result.answer);
|
||||
} catch {
|
||||
// Wails fallback — plain execCommand
|
||||
const el = document.createElement('textarea');
|
||||
el.value = result.answer;
|
||||
document.body.appendChild(el);
|
||||
el.select();
|
||||
document.execCommand('copy');
|
||||
const handleCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try { await navigator.clipboard.writeText(result.answer); }
|
||||
catch {
|
||||
const el = document.createElement('textarea'); el.value = result.answer;
|
||||
document.body.appendChild(el); el.select(); document.execCommand('copy');
|
||||
document.body.removeChild(el);
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
};
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (managementMode) { onToggleSelect(result.id); return; }
|
||||
// default: copy answer
|
||||
};
|
||||
|
||||
const catColor = CATEGORY_COLORS[result.category] ?? 'bg-white/10 text-white/50';
|
||||
|
||||
return (
|
||||
<div className={`result-card group relative ${result.is_fallback ? 'opacity-70' : ''}`} onClick={handleCopy}>
|
||||
{/* Category badge + match indicator */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className={`result-card group relative transition-all
|
||||
${result.is_fallback ? 'opacity-70' : ''}
|
||||
${selected ? 'ring-2 ring-accent/60 bg-accent/5' : ''}
|
||||
${managementMode ? 'cursor-pointer' : ''}`}>
|
||||
|
||||
{/* Top row: checkbox (mgmt) | category | match badge | trash */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{managementMode && (
|
||||
<div className={`w-4 h-4 rounded border flex-shrink-0 flex items-center justify-center transition-colors
|
||||
${selected ? 'bg-accent border-accent' : 'border-white/25'}`}>
|
||||
{selected && <span className="text-white text-[10px]">✓</span>}
|
||||
</div>
|
||||
)}
|
||||
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${catColor}`}>
|
||||
{result.category}
|
||||
</span>
|
||||
{result.is_fallback ? (
|
||||
<span className="text-[10px] text-white/30 flex items-center gap-1">
|
||||
🔥 热门推荐
|
||||
</span>
|
||||
) : result.score === 2 ? (
|
||||
<span className="text-[10px] text-accent-light flex items-center gap-1">
|
||||
⚡ 精准匹配
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-[10px] text-white/30 flex-1">
|
||||
{result.is_fallback ? '🔥 热门推荐' : result.score === 2 ? '⚡ 精准匹配' : null}
|
||||
</span>
|
||||
|
||||
{/* Trash icon — always visible, top-right */}
|
||||
{!managementMode && (
|
||||
confirmDelete ? (
|
||||
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
|
||||
<button onClick={() => { onDelete(result.id); setConfirmDelete(false); }}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-red-500/30 text-red-300 border border-red-500/40 hover:bg-red-500/50 transition-all">
|
||||
确认
|
||||
</button>
|
||||
<button onClick={() => setConfirmDelete(false)}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-white/5 text-white/40 border border-white/10 hover:bg-white/10 transition-all">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
id={`btn-delete-${result.id}`}
|
||||
onClick={e => { e.stopPropagation(); setConfirmDelete(true); }}
|
||||
className="opacity-0 group-hover:opacity-100 text-white/25 hover:text-red-400 transition-all text-sm leading-none p-1 rounded hover:bg-red-500/10"
|
||||
title="删除此条目">
|
||||
🗑
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Question */}
|
||||
<p className="text-[12px] text-white/50 mb-1.5 line-clamp-1">
|
||||
Q: {result.question}
|
||||
</p>
|
||||
<p className="text-[12px] text-white/50 mb-1.5 line-clamp-1">Q: {result.question}</p>
|
||||
|
||||
{/* Answer */}
|
||||
<p className="text-[13px] text-white/90 leading-relaxed line-clamp-3">
|
||||
{result.answer}
|
||||
</p>
|
||||
<p className="text-[13px] text-white/90 leading-relaxed line-clamp-3">{result.answer}</p>
|
||||
|
||||
{/* Hover action bar */}
|
||||
<div className="flex items-center gap-2 mt-3 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
id={`btn-copy-${result.id}`}
|
||||
onClick={e => { e.stopPropagation(); handleCopy(); }}
|
||||
className="flex-1 text-[11px] py-1.5 rounded-lg bg-accent/20 text-accent-light border border-accent/30
|
||||
hover:bg-accent/35 transition-all font-medium"
|
||||
>
|
||||
{copied ? '✓ 已复制' : '📋 复制'}
|
||||
</button>
|
||||
<button
|
||||
id={`btn-polish-${result.id}`}
|
||||
onClick={e => { e.stopPropagation(); onPolish(result); }}
|
||||
className="flex-1 text-[11px] py-1.5 rounded-lg bg-white/5 text-white/60 border border-white/10
|
||||
hover:bg-accent/15 hover:text-accent-light hover:border-accent/30 transition-all font-medium"
|
||||
>
|
||||
✨ AI 润色
|
||||
</button>
|
||||
</div>
|
||||
{/* Hover action bar (only in normal mode) */}
|
||||
{!managementMode && (
|
||||
<div className="flex items-center gap-2 mt-3 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button id={`btn-copy-${result.id}`} onClick={handleCopy}
|
||||
className="flex-1 text-[11px] py-1.5 rounded-lg bg-accent/20 text-accent-light border border-accent/30
|
||||
hover:bg-accent/35 transition-all font-medium">
|
||||
{copied ? '✓ 已复制' : '📋 复制'}
|
||||
</button>
|
||||
<button id={`btn-polish-${result.id}`} onClick={e => { e.stopPropagation(); onPolish(result); }}
|
||||
className="flex-1 text-[11px] py-1.5 rounded-lg bg-white/5 text-white/60 border border-white/10
|
||||
hover:bg-accent/15 hover:text-accent-light hover:border-accent/30 transition-all font-medium">
|
||||
✨ AI 润色
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Copy toast */}
|
||||
{copied && (
|
||||
<div className="copy-toast absolute top-2 right-2 text-[10px] bg-green-500/20 text-green-400
|
||||
border border-green-500/30 px-2 py-1 rounded-md pointer-events-none">
|
||||
已复制到剪贴板 ✓
|
||||
已复制 ✓
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export interface ToastItem {
|
||||
id: number;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
}
|
||||
|
||||
interface ToastProps { toasts: ToastItem[]; }
|
||||
|
||||
export function Toast({ toasts }: ToastProps) {
|
||||
return (
|
||||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[100] flex flex-col gap-2 items-center pointer-events-none">
|
||||
{toasts.map(t => (
|
||||
<div key={t.id}
|
||||
className={`px-4 py-2 rounded-xl text-[12px] font-medium shadow-xl border animate-toast-in
|
||||
${t.type === 'success' ? 'bg-green-500/20 text-green-300 border-green-500/30' :
|
||||
t.type === 'error' ? 'bg-red-500/20 text-red-300 border-red-500/30' :
|
||||
'bg-white/10 text-white/70 border-white/15'}`}>
|
||||
{t.type === 'success' ? '✓ ' : t.type === 'error' ? '✕ ' : 'ℹ '}{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let _toastId = 0;
|
||||
|
||||
export function useToast() {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const timerRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
const showToast = useCallback((message: string, type: ToastItem['type'] = 'success', duration = 2500) => {
|
||||
const id = ++_toastId;
|
||||
setToasts(prev => [...prev, { id, message, type }]);
|
||||
const timer = setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
timerRef.current.delete(id);
|
||||
}, duration);
|
||||
timerRef.current.set(id, timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timers = timerRef.current;
|
||||
return () => { timers.forEach(t => clearTimeout(t)); };
|
||||
}, []);
|
||||
|
||||
return { toasts, showToast };
|
||||
}
|
||||
@@ -43,5 +43,5 @@ export function useSearch(debounceMs = 300) {
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, [query, debounceMs, doSearch]);
|
||||
|
||||
return { query, setQuery, results, loading, error };
|
||||
return { query, setQuery, results, setResults, loading, error };
|
||||
}
|
||||
|
||||
@@ -89,9 +89,14 @@ body {
|
||||
.copy-toast {
|
||||
animation: fadeIn 0.15s ease, fadeOut 0.15s ease 0.85s forwards;
|
||||
}
|
||||
@keyframes fadeOut { to { opacity: 0; transform: translateY(-4px); } }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } }
|
||||
@keyframes slideIn { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:translateY(0); } }
|
||||
@keyframes fadeOut { to { opacity: 0; transform: translateY(-4px); } }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } }
|
||||
@keyframes slideIn { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:translateY(0); } }
|
||||
@keyframes toastIn { from { opacity:0; transform:translateY(12px) scale(0.95); } to { opacity:1; transform:translateY(0) scale(1); } }
|
||||
@keyframes dropIn { from { opacity:0; transform:translateY(-6px) scale(0.98); } to { opacity:1; transform:translateY(0) scale(1); } }
|
||||
|
||||
.animate-toast-in { animation: toastIn 0.2s cubic-bezier(0.34,1.56,0.64,1); }
|
||||
.animate-fade-in { animation: dropIn 0.15s ease; }
|
||||
|
||||
/* ── Input ───────────────────────────────────────────── */
|
||||
.search-input {
|
||||
|
||||
Vendored
+6
@@ -5,8 +5,12 @@ import {service} from '../models';
|
||||
|
||||
export function AskDeepSeek(arg1:string,arg2:string):Promise<string>;
|
||||
|
||||
export function ClearDatabase():Promise<string>;
|
||||
|
||||
export function CreateLibrary(arg1:string,arg2:string):Promise<string>;
|
||||
|
||||
export function DeleteItems(arg1:Array<number>):Promise<string>;
|
||||
|
||||
export function DeleteLibrary(arg1:string):Promise<string>;
|
||||
|
||||
export function GetActiveLibrary():Promise<string>;
|
||||
@@ -19,6 +23,8 @@ export function GetSettings():Promise<service.SettingsDTO>;
|
||||
|
||||
export function ImportCSV():Promise<service.ImportResult>;
|
||||
|
||||
export function ImportExcel():Promise<service.ImportResult>;
|
||||
|
||||
export function ListLibraries():Promise<Array<handler.LibraryInfo>>;
|
||||
|
||||
export function SaveSettings(arg1:service.SettingsDTO):Promise<string>;
|
||||
|
||||
@@ -6,10 +6,18 @@ export function AskDeepSeek(arg1, arg2) {
|
||||
return window['go']['main']['App']['AskDeepSeek'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ClearDatabase() {
|
||||
return window['go']['main']['App']['ClearDatabase']();
|
||||
}
|
||||
|
||||
export function CreateLibrary(arg1, arg2) {
|
||||
return window['go']['main']['App']['CreateLibrary'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function DeleteItems(arg1) {
|
||||
return window['go']['main']['App']['DeleteItems'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteLibrary(arg1) {
|
||||
return window['go']['main']['App']['DeleteLibrary'](arg1);
|
||||
}
|
||||
@@ -34,6 +42,10 @@ export function ImportCSV() {
|
||||
return window['go']['main']['App']['ImportCSV']();
|
||||
}
|
||||
|
||||
export function ImportExcel() {
|
||||
return window['go']['main']['App']['ImportExcel']();
|
||||
}
|
||||
|
||||
export function ListLibraries() {
|
||||
return window['go']['main']['App']['ListLibraries']();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user