Files
MyNote/apps/desktop/src/renderer/features/doc-info/DocInfoPanel.tsx
T

154 lines
11 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 { 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 };