154 lines
11 KiB
TypeScript
154 lines
11 KiB
TypeScript
import { useState } from 'react';
|
||
import { useStore } from '../../store';
|
||
import { Star, Tag, Link2, FileText, Clock, Plus, X, Check, ListChecks, ArrowLeftRight, Folder, ChevronDown } from 'lucide-react';
|
||
|
||
// 信息面板:路径/标签/关联文档(双向+批量)/时间。样式统一走设计 token
|
||
export function DocInfoPanel() {
|
||
const currentPath = useStore(s => s.currentPath);
|
||
const title = useStore(s => s.title);
|
||
const meta = useStore(s => s.meta);
|
||
const tree = useStore(s => s.tree);
|
||
const toggleStar = useStore(s => s.toggleStar);
|
||
const addTag = useStore(s => s.addTag);
|
||
const removeTag = useStore(s => s.removeTag);
|
||
const linkBatch = useStore(s => s.linkBatch);
|
||
const openDoc = useStore(s => s.openDoc);
|
||
const [tagInput, setTagInput] = useState('');
|
||
const [quickSel, setQuickSel] = useState('');
|
||
const [batchOpen, setBatchOpen] = useState(false);
|
||
const [checked, setChecked] = useState<Set<string>>(new Set());
|
||
|
||
if (!currentPath) {
|
||
return <div style={{ padding: 16, opacity: 0.5, fontSize: 13 }}>未打开文档</div>;
|
||
}
|
||
|
||
// 库内 .md 文件供关联选择(排除当前文档与已关联项)
|
||
const docs = tree.filter(n => n.type === 'doc');
|
||
const mtime = tree.find(n => n.path === currentPath)?.mtime;
|
||
const links = meta.links ?? [];
|
||
const linkNames = new Map<string, string>();
|
||
for (const d of docs) linkNames.set(d.path, d.name);
|
||
const quickCandidates = docs.filter(d => d.path !== currentPath && !links.includes(d.path));
|
||
|
||
const toggleCheck = (p: string) => {
|
||
const n = new Set(checked); if (n.has(p)) n.delete(p); else n.add(p); setChecked(n);
|
||
};
|
||
|
||
return (
|
||
<div style={{ padding: 14, overflow: 'auto', fontSize: 13, display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||
{/* 标题 + 星标 */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingBottom: 12, borderBottom: '1px solid var(--control-border)' }}>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{title}</div>
|
||
</div>
|
||
<button onClick={toggleStar} title={meta.star ? '取消星标' : '星标'} style={iconBtn} aria-pressed={!!meta.star}>
|
||
<Star size={16} fill={meta.star ? 'currentColor' : 'none'} color={meta.star ? 'var(--accent)' : 'var(--text-tertiary)'} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* 路径 */}
|
||
<Section icon={<FileText size={13} />} title="路径">
|
||
<div style={{ color: 'var(--text-tertiary)', wordBreak: 'break-all', lineHeight: 1.5 }}>{currentPath}</div>
|
||
</Section>
|
||
|
||
{/* 标签 */}
|
||
<Section icon={<Tag size={13} />} title="标签">
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
|
||
{(meta.tags ?? []).map(t => (
|
||
<span key={t} style={chip}>
|
||
{t}
|
||
<button style={chipX} onClick={() => removeTag(t)} title="移除标签"><X size={12} /></button>
|
||
</span>
|
||
))}
|
||
{(meta.tags ?? []).length === 0 && <span style={{ color: 'var(--text-tertiary)', opacity: 0.6 }}>暂无标签</span>}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 6 }}>
|
||
<input value={tagInput} onChange={e => setTagInput(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && tagInput.trim()) { addTag(tagInput.trim()); setTagInput(''); } }} placeholder="添加标签,回车确认" style={inp} />
|
||
<button style={btn()} title="添加标签" onClick={() => { if (tagInput.trim()) { addTag(tagInput.trim()); setTagInput(''); } }}><Plus size={14} /></button>
|
||
</div>
|
||
</Section>
|
||
|
||
{/* 关联文档:双向 + 批量 */}
|
||
<Section icon={<Link2 size={13} />} title={"关联文档" + (links.length ? '(' + links.length + ')' : '')}>
|
||
{links.length === 0 ? (
|
||
<div style={{ color: 'var(--text-tertiary)', opacity: 0.6, marginBottom: 8 }}>暂无关联,可在下方快速关联或批量关联</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 10 }}>
|
||
{links.map(l => (
|
||
<div key={l} title={l} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 10px', background: 'var(--bg-secondary)', borderRadius: 'var(--radius-sm)', border: '1px solid var(--control-border)', boxShadow: 'var(--shadow-soft)', transition: 'border-color .15s var(--ease), box-shadow .15s var(--ease)' }} onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--line-strong)'; e.currentTarget.style.boxShadow = 'var(--shadow-lift)'; }} onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--control-border)'; e.currentTarget.style.boxShadow = 'var(--shadow-soft)'; }}>
|
||
<span style={{ display: 'inline-flex', color: 'var(--text-tertiary)', flexShrink: 0 }}><Link2 size={14} /></span>
|
||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text-primary)', fontWeight: 500 }}>{linkNames.get(l) ?? l}</span>
|
||
<span style={{ fontSize: 10, color: 'var(--text-tertiary)', flexShrink: 0, display: 'inline-flex', alignItems: 'center', gap: 3 }}><ArrowLeftRight size={11} />双向</span>
|
||
<button style={iconSmall} title="打开" onClick={() => openDoc(l)}><FileText size={13} /></button>
|
||
<button style={iconSmall} title="解除关联(双向)" onClick={() => void linkBatch([l], true)}><X size={13} /></button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* 快速双向关联 */}
|
||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 10 }}>
|
||
<ArrowLeftRight size={14} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} aria-label="快速双向关联" />
|
||
<select value={quickSel} onChange={e => setQuickSel(e.target.value)} style={{ ...inp, flex: 1 }}>
|
||
<option value="">选择文档,双向关联…</option>
|
||
{quickCandidates.map(d => (<option key={d.path} value={d.path}>{d.name}</option>))}
|
||
</select>
|
||
<button style={btnPrimary(!quickSel)} title="建立双向关联" onClick={() => { if (quickSel) { void linkBatch([quickSel], false); setQuickSel(''); } }} disabled={!quickSel}>关联</button>
|
||
</div>
|
||
|
||
{/* 批量关联 */}
|
||
<div style={{ border: '1px solid var(--control-border)', borderRadius: 6, overflow: 'hidden' }}>
|
||
<button style={{ width: '100%', display: 'flex', alignItems: 'center', gap: 6, padding: '6px 8px', background: 'transparent', border: 'none', color: 'var(--text-secondary)', cursor: 'pointer' }} onClick={() => setBatchOpen(o => !o)}>
|
||
<ListChecks size={14} />批量关联
|
||
<ChevronDown size={14} style={{ marginLeft: 'auto', transform: batchOpen ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }} />
|
||
</button>
|
||
{batchOpen && (
|
||
<div style={{ borderTop: '1px solid var(--control-border)', maxHeight: 180, overflow: 'auto' }}>
|
||
<div style={{ padding: 4, display: 'flex', flexDirection: 'column' }}>
|
||
{quickCandidates.length === 0 ? (
|
||
<div style={{ padding: '6px 8px', color: 'var(--text-tertiary)', opacity: 0.6 }}>没有可关联的文档</div>
|
||
) : quickCandidates.map(d => (
|
||
<label key={d.path} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '3px 8px', borderRadius: 4, cursor: 'pointer' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--control-border)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'} >
|
||
<input type="checkbox" checked={checked.has(d.path)} onChange={() => toggleCheck(d.path)} style={{ accentColor: 'var(--accent)' }} />
|
||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.name}</span>
|
||
{checked.has(d.path) && <Check size={13} style={{ color: 'var(--accent)' }} />}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{batchOpen && (
|
||
<div style={{ display: 'flex', gap: 6, padding: '6px 8px', borderTop: '1px solid var(--control-border)', alignItems: 'center' }}>
|
||
<button style={btn(quickCandidates.length === 0)} title="全部选中" onClick={() => { const n = new Set(checked); quickCandidates.forEach(d => n.add(d.path)); setChecked(n); }} disabled={quickCandidates.length === 0}><Folder size={13} />全选</button>
|
||
<button style={btnPrimary(checked.size === 0)} title="为勾选文档建立双向关联" disabled={checked.size === 0} onClick={() => { const arr = [...checked]; void linkBatch(arr, false); setChecked(new Set()); setBatchOpen(false); }}>关联选中({checked.size})</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Section>
|
||
|
||
{/* 时间 */}
|
||
<Section icon={<Clock size={13} />} title="时间">
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, color: 'var(--text-tertiary)' }}>
|
||
<div>修改:{mtime ? new Date(mtime).toLocaleString('zh-CN') : '—'}</div>
|
||
<div>创建:{meta.createdAt ? new Date(meta.createdAt).toLocaleString('zh-CN') : '—'}</div>
|
||
</div>
|
||
</Section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Section({ icon, title, children }: { icon: React.ReactNode; title: string; children: React.ReactNode }) {
|
||
return (
|
||
<div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--text-tertiary)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 8 }}>{icon}{title}</div>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
const chip: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', gap: 4, background: 'var(--control-border)', color: 'var(--text-secondary)', borderRadius: 999, padding: '2px 8px', fontSize: 12 };
|
||
const chipX: React.CSSProperties = { border: 'none', background: 'transparent', color: 'var(--text-tertiary)', cursor: 'pointer', display: 'inline-flex', padding: 0, borderRadius: 4 };
|
||
const inp: React.CSSProperties = { background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', color: 'var(--text-primary)', borderRadius: 6, padding: '5px 8px', fontSize: 13, flex: 1, outline: 'none' };
|
||
const btn = (disabled?: boolean): React.CSSProperties => ({ minWidth: 30, height: 28, border: '1px solid var(--control-border)', background: 'transparent', color: 'var(--text-secondary)', cursor: disabled ? 'not-allowed' : 'pointer', borderRadius: 6, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 4, padding: '0 8px', fontSize: 12, opacity: disabled ? 0.4 : 1 });
|
||
const btnPrimary = (disabled?: boolean): React.CSSProperties => ({ minWidth: 52, height: 28, border: 'none', background: 'var(--accent)', color: '#fff', cursor: disabled ? 'not-allowed' : 'pointer', borderRadius: 6, padding: '0 10px', fontSize: 12, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', opacity: disabled ? 0.4 : 1 });
|
||
const iconBtn: React.CSSProperties = { border: 'none', background: 'transparent', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', padding: 4, borderRadius: 6 };
|
||
const iconSmall: React.CSSProperties = { border: 'none', background: 'transparent', color: 'var(--text-tertiary)', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', padding: 2, borderRadius: 4 }; |