chore: sync local updates

This commit is contained in:
2026-05-16 19:03:44 +08:00
parent de9fd187e6
commit d49287704b
227 changed files with 16044 additions and 12171 deletions

View File

@@ -0,0 +1,195 @@
.admin-panel-backdrop {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 2.5rem 1rem 2rem;
overflow-y: auto;
background: rgba(31, 35, 40, 0.4);
backdrop-filter: blur(3px);
}
.admin-panel {
width: 100%;
max-width: 520px;
max-height: min(90vh, 720px);
display: flex;
flex-direction: column;
padding: 1.25rem 1.25rem 1.35rem;
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-soft);
}
.admin-panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.admin-panel-title {
margin: 0;
font-size: 1.15rem;
font-weight: 600;
color: var(--color-text);
}
.admin-panel-header-actions {
display: flex;
align-items: center;
gap: 0.35rem;
flex-shrink: 0;
}
.admin-panel-text-btn {
padding: 0.25rem 0.45rem;
font-size: 0.82rem;
color: var(--color-muted);
background: transparent;
border: none;
cursor: pointer;
text-decoration: underline;
}
.admin-panel-text-btn:hover {
color: var(--color-accent);
}
.admin-panel-close {
width: 32px;
height: 32px;
padding: 0;
font-size: 1.4rem;
line-height: 1;
color: var(--color-muted);
background: transparent;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
}
.admin-panel-close:hover {
background: var(--color-surface);
color: var(--color-text);
}
.admin-panel-desc {
margin: 0 0 1rem;
font-size: 0.82rem;
line-height: 1.45;
color: var(--color-muted);
}
.admin-panel-add {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.admin-panel-input {
flex: 1;
min-width: 140px;
padding: 0.45rem 0.55rem;
font-size: 0.9rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: #fff;
color: var(--color-text);
}
.admin-panel-input.inline {
flex: 1 1 auto;
min-width: 0;
}
.admin-panel-input:focus {
outline: 2px solid var(--color-accent);
outline-offset: 0;
border-color: var(--color-accent);
}
.admin-panel-btn {
padding: 0.45rem 0.75rem;
font-size: 0.88rem;
border-radius: var(--radius-md);
cursor: pointer;
border: 1px solid var(--color-border);
background: #fff;
color: var(--color-text);
white-space: nowrap;
}
.admin-panel-btn.primary {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
}
.admin-panel-btn.primary:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.admin-panel-btn.secondary {
background: var(--color-surface);
}
.admin-panel-btn.danger {
border-color: rgba(207, 34, 46, 0.45);
color: var(--color-danger);
}
.admin-panel-btn.small {
padding: 0.28rem 0.5rem;
font-size: 0.8rem;
}
.admin-panel-error {
margin: 0 0 0.65rem;
font-size: 0.85rem;
color: var(--color-danger);
}
.admin-panel-muted {
margin: 0 0 0.5rem;
font-size: 0.85rem;
color: var(--color-muted);
}
.admin-panel-list-wrap {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.admin-panel-list {
list-style: none;
margin: 0;
padding: 0;
}
.admin-panel-row {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
padding: 0.45rem 0;
border-bottom: 1px solid rgba(208, 215, 222, 0.6);
}
.admin-panel-row:last-child {
border-bottom: none;
}
.admin-panel-name {
flex: 1;
min-width: 120px;
font-size: 0.9rem;
word-break: break-all;
}

View File

@@ -0,0 +1,207 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
addIgnoreFolder,
deleteIgnoreFolder,
fetchIgnoreList,
updateIgnoreFolder,
} from '../utils/fileUtils';
import { useAdmin } from '../context/AdminContext';
import './AdminPanel.css';
export default function AdminPanel() {
const {
adminPanelOpen,
closeAdminPanel,
logoutAdmin,
getStoredToken,
notifyIgnoreListChanged,
} = useAdmin();
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [newName, setNewName] = useState('');
const [editing, setEditing] = useState(null);
const [editValue, setEditValue] = useState('');
const load = useCallback(async () => {
const token = getStoredToken();
if (!token) {
setError('未登录');
return;
}
setLoading(true);
setError(null);
try {
const data = await fetchIgnoreList(token);
setItems(data.ignore || []);
} catch (e) {
setError(e.message || '加载失败');
setItems([]);
} finally {
setLoading(false);
}
}, [getStoredToken]);
useEffect(() => {
if (adminPanelOpen) {
load();
}
}, [adminPanelOpen, load]);
if (!adminPanelOpen) {
return null;
}
const token = getStoredToken();
const handleAdd = async (e) => {
e.preventDefault();
const name = newName.trim();
if (!name || !token) return;
setError(null);
try {
const data = await addIgnoreFolder(token, name);
setItems(data.ignore || []);
setNewName('');
notifyIgnoreListChanged();
} catch (err) {
setError(err.message || '添加失败');
}
};
const handleDelete = async (name) => {
if (!token || !window.confirm(`确定从忽略列表中移除「${name}」?`)) return;
setError(null);
try {
const data = await deleteIgnoreFolder(token, name);
setItems(data.ignore || []);
notifyIgnoreListChanged();
} catch (err) {
setError(err.message || '删除失败');
}
};
const startEdit = (name) => {
setEditing(name);
setEditValue(name);
};
const cancelEdit = () => {
setEditing(null);
setEditValue('');
};
const saveEdit = async () => {
if (!token || editing == null) return;
const next = editValue.trim();
if (!next || next === editing) {
cancelEdit();
return;
}
setError(null);
try {
const data = await updateIgnoreFolder(token, editing, next);
setItems(data.ignore || []);
cancelEdit();
notifyIgnoreListChanged();
} catch (err) {
setError(err.message || '保存失败');
}
};
return (
<div
className="admin-panel-backdrop"
role="presentation"
onMouseDown={(e) => {
if (e.target === e.currentTarget) closeAdminPanel();
}}
>
<div
className="admin-panel"
role="dialog"
aria-modal="true"
aria-labelledby="admin-panel-title"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="admin-panel-header">
<h2 id="admin-panel-title" className="admin-panel-title">
后台 · 忽略的笔记文件夹
</h2>
<div className="admin-panel-header-actions">
<button type="button" className="admin-panel-text-btn" onClick={logoutAdmin}>
退出登录
</button>
<button type="button" className="admin-panel-close" onClick={closeAdminPanel} aria-label="关闭">
×
</button>
</div>
</div>
<p className="admin-panel-desc">
以下名称与笔记根目录下的一级文件夹名匹配匹配的文件夹不会出现在公开目录树中
</p>
<form className="admin-panel-add" onSubmit={handleAdd}>
<input
type="text"
className="admin-panel-input"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="添加文件夹名称"
aria-label="新忽略文件夹名"
/>
<button type="submit" className="admin-panel-btn primary" disabled={!newName.trim()}>
添加
</button>
</form>
{error && (
<p className="admin-panel-error" role="alert">
{error}
</p>
)}
<div className="admin-panel-list-wrap">
{loading && <p className="admin-panel-muted">加载中</p>}
{!loading && items.length === 0 && !error && (
<p className="admin-panel-muted">暂无忽略项可在上方添加</p>
)}
<ul className="admin-panel-list">
{items.map((name) => (
<li key={name} className="admin-panel-row">
{editing === name ? (
<>
<input
type="text"
className="admin-panel-input inline"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
aria-label="编辑名称"
/>
<button type="button" className="admin-panel-btn small" onClick={saveEdit}>
保存
</button>
<button type="button" className="admin-panel-btn small secondary" onClick={cancelEdit}>
取消
</button>
</>
) : (
<>
<span className="admin-panel-name">{name}</span>
<button type="button" className="admin-panel-btn small secondary" onClick={() => startEdit(name)}>
编辑
</button>
<button type="button" className="admin-panel-btn small danger" onClick={() => handleDelete(name)}>
删除
</button>
</>
)}
</li>
))}
</ul>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
.admin-token-backdrop {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(31, 35, 40, 0.45);
backdrop-filter: blur(4px);
}
.admin-token-dialog {
width: 100%;
max-width: 380px;
padding: 1.35rem 1.25rem 1.25rem;
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-soft);
}
.admin-token-title {
margin: 0 0 0.35rem;
font-size: 1.15rem;
font-weight: 600;
color: var(--color-text);
}
.admin-token-hint {
margin: 0 0 1rem;
font-size: 0.88rem;
color: var(--color-muted);
}
.admin-token-input {
width: 100%;
box-sizing: border-box;
padding: 0.55rem 0.65rem;
font-size: 0.95rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: #fff;
color: var(--color-text);
}
.admin-token-input:focus {
outline: 2px solid var(--color-accent);
outline-offset: 0;
border-color: var(--color-accent);
}
.admin-token-error {
margin: 0.5rem 0 0;
font-size: 0.85rem;
color: var(--color-danger);
}
.admin-token-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
.admin-token-btn {
min-width: 72px;
padding: 0.45rem 0.85rem;
font-size: 0.9rem;
border-radius: var(--radius-md);
cursor: pointer;
border: 1px solid var(--color-border);
background: #fff;
color: var(--color-text);
}
.admin-token-btn.primary {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
}
.admin-token-btn.secondary:hover {
background: var(--color-surface);
}
.admin-token-btn.primary:hover {
filter: brightness(1.05);
}

View File

@@ -0,0 +1,76 @@
import React, { useEffect, useRef, useState } from 'react';
import { useAdmin } from '../context/AdminContext';
import './AdminTokenModal.css';
export default function AdminTokenModal() {
const {
tokenModalOpen,
tokenSubmitError,
submitToken,
closeTokenModal,
} = useAdmin();
const [value, setValue] = useState('');
const inputRef = useRef(null);
useEffect(() => {
if (tokenModalOpen) {
setValue('');
requestAnimationFrame(() => inputRef.current?.focus());
}
}, [tokenModalOpen]);
if (!tokenModalOpen) {
return null;
}
const handleSubmit = (e) => {
e.preventDefault();
submitToken(value.trim());
};
return (
<div
className="admin-token-backdrop"
role="presentation"
onMouseDown={(e) => {
if (e.target === e.currentTarget) closeTokenModal();
}}
>
<div
className="admin-token-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="admin-token-title"
>
<h2 id="admin-token-title" className="admin-token-title">
管理员验证
</h2>
<p className="admin-token-hint">请输入访问令牌</p>
<form onSubmit={handleSubmit}>
<input
ref={inputRef}
type="password"
className="admin-token-input"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Token"
autoComplete="off"
/>
{tokenSubmitError && (
<p className="admin-token-error" role="alert">
{tokenSubmitError}
</p>
)}
<div className="admin-token-actions">
<button type="button" className="admin-token-btn secondary" onClick={closeTokenModal}>
取消
</button>
<button type="submit" className="admin-token-btn primary">
确定
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -9,10 +9,13 @@
.markdown-renderer {
flex: 1;
height: 100vh;
min-height: 0;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
background: #ffffff;
/* 到达边界不传播到外层,彻底隔离滚动 */
overscroll-behavior: contain;
/* 隐藏滚动条但保留滚动功能 */
scrollbar-width: none; /* Firefox */
@@ -31,7 +34,6 @@
width: 100%;
margin: 0;
padding: 45px;
min-height: 100vh;
}
/* ========================================
@@ -42,11 +44,84 @@
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32px;
margin-bottom: 12px;
padding-bottom: 16px;
border-bottom: 1px solid #d0d7de;
}
/* 路径栏 */
/* 元数据框内的横向分割线 */
.metadata-divider-h {
width: 100%;
height: 1px;
background: #e1e4e8;
margin: 4px 0;
}
/* 路径栏:作为 metadata-content 内部的一个全宽行 */
.note-path-bar {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 5px 8px;
background: #eef2f6;
border: 1px solid #d0d7de;
border-radius: 5px;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 12px;
color: #57606a;
text-align: left;
white-space: nowrap;
overflow: hidden;
/* 撑满父容器宽度 */
box-sizing: border-box;
}
.note-path-bar:hover {
background: #dbe4f0;
border-color: #a8b8cc;
color: #0969da;
}
.note-path-bar--copied {
background: #dafbe1;
border-color: #2da44e;
color: #1a7f37;
}
.note-path-icon {
flex-shrink: 0;
display: inline-flex;
align-items: center;
opacity: 0.65;
}
.note-path-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.note-path-copy-hint {
flex-shrink: 0;
font-size: 11px;
padding: 1px 7px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.07);
color: inherit;
opacity: 0.8;
}
.note-path-bar:hover .note-path-copy-hint,
.note-path-bar--copied .note-path-copy-hint {
opacity: 1;
}
.file-title {
margin: 0;
font-size: 28px;
@@ -469,8 +544,8 @@
======================================== */
.file-metadata {
margin-top: 60px;
padding-top: 32px;
margin-top: 48px;
padding-top: 0;
}
.metadata-divider {
@@ -483,11 +558,18 @@
.metadata-content {
display: flex;
flex-wrap: wrap;
gap: 16px 32px;
padding: 20px 24px;
background: #f6f8fa;
border-radius: 6px;
border: 1px solid #d0d7de;
align-items: center;
gap: 12px 28px;
padding: 0;
background: none;
border: none;
border-radius: 0;
}
/* 让分割线和路径栏独占一整行 */
.metadata-divider-h,
.note-path-bar {
flex-basis: 100%;
}
.metadata-item {

View File

@@ -13,7 +13,7 @@ import 'katex/dist/katex.min.css';
import 'highlight.js/styles/github.css';
function MarkdownRenderer() {
const { currentContent, currentFile, currentMetadata, isLoading, error } = useApp();
const { currentContent, currentFile, currentMetadata, isLoading, error, directoryTree } = useApp();
const contentRef = useRef(null);
const [copySuccess, setCopySuccess] = useState(false);
const [codeCopySuccess, setCodeCopySuccess] = useState({});
@@ -93,6 +93,45 @@ function MarkdownRenderer() {
return fileName.replace(/\.md$/i, '');
};
// 构建当前笔记的完整 URL显示用保留中文原文
const getNoteUrlDisplay = () => {
if (!currentFile) return '';
return `${window.location.origin}/note/${currentFile}`;
};
// 构建当前笔记的完整 URL复制用每段 encodeURIComponent
const getNoteUrlEncoded = () => {
if (!currentFile) return '';
const encoded = currentFile.split('/').map(encodeURIComponent).join('/');
return `${window.location.origin}/note/${encoded}`;
};
const [pathCopied, setPathCopied] = useState(false);
const handleCopyPath = async () => {
const url = getNoteUrlEncoded();
if (!url) return;
try {
// 优先用 Clipboard API降级到 execCommand
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(url);
} else {
const ta = document.createElement('textarea');
ta.value = url;
ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0';
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
setPathCopied(true);
setTimeout(() => setPathCopied(false), 2000);
} catch {
/* ignore */
}
};
// 复制内容到剪贴板
const handleCopy = async () => {
try {
@@ -147,8 +186,19 @@ function MarkdownRenderer() {
{!isLoading && !error && !currentContent && (
<div className="empty-state" style={{ padding: '50px', textAlign: 'center', color: '#888' }}>
<p>请选择左侧文件查看内容</p>
<p style={{ fontSize: '0.8em', marginTop: '10px' }}>Select a file to view content</p>
{directoryTree.length === 0 ? (
<>
<p>当前没有可展示的笔记</p>
<p style={{ fontSize: '0.8em', marginTop: '10px', maxWidth: '420px', marginLeft: 'auto', marginRight: 'auto' }}>
目录树为空若已部署后端请检查笔记目录挂载与 /api/health 中的 markdown_root
</p>
</>
) : (
<>
<p>请选择左侧文件查看内容</p>
<p style={{ fontSize: '0.8em', marginTop: '10px' }}>Select a file to view content</p>
</>
)}
</div>
)}
@@ -269,38 +319,57 @@ function MarkdownRenderer() {
{currentContent}
</ReactMarkdown>
{/* 显示文件元数据 */}
{currentMetadata && (
<div className="file-metadata">
<div className="metadata-divider"></div>
<div className="metadata-content">
<div className="metadata-item" title="字数统计">
<span className="metadata-icon">📝</span>
<span className="metadata-label">字数:</span>
<span className="metadata-value">{currentMetadata.wordCount}</span>
</div>
<div className="metadata-item" title="文件大小">
<span className="metadata-icon">💾</span>
<span className="metadata-label">大小:</span>
<span className="metadata-value">{formatFileSize(currentMetadata.fileSize)}</span>
</div>
{currentMetadata.createdTime && (
<div className="metadata-item" title="创建时间">
<span className="metadata-icon">📅</span>
<span className="metadata-label">创建于:</span>
<span className="metadata-value">{currentMetadata.createdTime}</span>
{/* 文章底部信息栏:元数据 + 路径 */}
<div className="file-metadata">
<div className="metadata-divider"></div>
<div className="metadata-content">
{currentMetadata && (
<>
<div className="metadata-item" title="字数统计">
<span className="metadata-icon">📝</span>
<span className="metadata-label">字数:</span>
<span className="metadata-value">{currentMetadata.wordCount}</span>
</div>
)}
{currentMetadata.modifiedTime && (
<div className="metadata-item" title="修改时间">
<span className="metadata-icon">🕒</span>
<span className="metadata-label">最后修改于:</span>
<span className="metadata-value">{currentMetadata.modifiedTime}</span>
<div className="metadata-item" title="文件大小">
<span className="metadata-icon">💾</span>
<span className="metadata-label">大小:</span>
<span className="metadata-value">{formatFileSize(currentMetadata.fileSize)}</span>
</div>
)}
</div>
{currentMetadata.createdTime && (
<div className="metadata-item" title="创建时间">
<span className="metadata-icon">📅</span>
<span className="metadata-label">创建于:</span>
<span className="metadata-value">{currentMetadata.createdTime}</span>
</div>
)}
{currentMetadata.modifiedTime && (
<div className="metadata-item" title="修改时间">
<span className="metadata-icon">🕒</span>
<span className="metadata-label">最后修改于:</span>
<span className="metadata-value">{currentMetadata.modifiedTime}</span>
</div>
)}
<div className="metadata-divider-h"></div>
</>
)}
{/* 路径行 */}
<button
type="button"
className={`note-path-bar${pathCopied ? ' note-path-bar--copied' : ''}`}
onClick={handleCopyPath}
title="点击复制链接"
>
<span className="note-path-icon" aria-hidden>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
</span>
<span className="note-path-text">{getNoteUrlDisplay()}</span>
<span className="note-path-copy-hint">{pathCopied ? '✓ 已复制' : '复制'}</span>
</button>
</div>
)}
</div>
</article>
</>
)}

View File

@@ -1,367 +1,526 @@
/* ========================================
侧边栏组件样式文件 (Sidebar.css)
用途:定义侧边栏布局、交互效果和响应式行为
======================================== */
/* ========================================
侧边栏主容器
======================================== */
/* 侧边栏基础样式 - GitHub 风格 */
.sidebar {
position: relative;
z-index: 110; /* 高层级确保在其他元素之上 */
width: var(--sidebar-width); /* 使用 CSS 变量控制宽度 */
height: 100vh; /* 全屏高度 */
/* GitHub 风格背景 */
background: #f6f8fa;
border-right: 1px solid #d0d7de;
display: flex;
flex-direction: column;
padding: 1.5rem 1rem 1.75rem; /* 上 左右 下 内边距 */
color: var(--color-text);
box-shadow: none;
overflow: hidden; /* 防止内容溢出 */
font-family: 'LXGWWenKaiMono', ui-monospace, var(--font-family-base);
}
/* 侧边栏关闭状态 - 完全隐藏 */
.sidebar.closed {
width: 0;
padding: 0;
opacity: 0;
pointer-events: none; /* 禁用鼠标事件 */
border-right: none;
}
/* ========================================
侧边栏头部区域
======================================== */
/* 侧边栏头部布局 */
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between; /* 左右分布:标题在左,按钮在右 */
gap: 0.75rem; /* 元素间距 */
margin-bottom: 1.5rem;
}
/* 侧边栏标题样式 */
.sidebar-header h2 {
margin: 0;
font-size: 1.1rem;
letter-spacing: 0.02em; /* 字母间距 */
font-weight: 600;
color: var(--color-text);
font-family: 'LXGWWenKaiMono', ui-monospace, var(--font-family-base);
flex-shrink: 0; /* 标题不收缩 */
}
/* ========================================
切换按钮样式
======================================== */
/* 侧边栏切换按钮 - 柔和的 GitHub 风格 */
.toggle-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
height: 36px;
padding: 0 0.6rem;
/* 柔和的 GitHub 风格按钮 */
background: rgba(255, 255, 255, 0.7);
color: var(--color-muted);
border: 1px solid rgba(208, 215, 222, 0.5);
border-radius: 6px;
font-size: 0.9rem;
line-height: 1;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 3px rgba(31, 35, 40, 0.06);
font-family: 'LXGWWenKaiMono', ui-monospace, var(--font-family-base);
flex-shrink: 0; /* 按钮不收缩 */
margin-left: auto; /* 按钮靠右 */
opacity: 0.8;
}
/* 切换按钮悬停状态 - 柔和的 GitHub 风格 */
.toggle-button:hover {
background: rgba(255, 255, 255, 1);
border-color: var(--color-accent);
color: var(--color-accent);
opacity: 1;
transform: translateX(2px);
box-shadow: 0 2px 6px rgba(9, 105, 218, 0.12);
}
/* 切换按钮激活状态 */
.toggle-button:active {
transform: translateX(0);
box-shadow: 0 1px 3px rgba(9, 105, 218, 0.1);
}
/* ========================================
侧边栏内容区域
======================================== */
/* 侧边栏滚动内容区 */
.sidebar-content {
flex: 1; /* 占据剩余空间 */
overflow-y: auto; /* 垂直滚动 */
overflow-x: hidden; /* 隐藏水平滚动 */
padding-right: 0.5rem; /* 右侧内边距 */
height: 0; /* 配合 flex: 1 实现正确的滚动 */
min-height: 0;
}
/* ========================================
目录树结构
======================================== */
/* 目录树容器 */
.directory-tree {
display: flex;
flex-direction: column;
gap: 0rem; /* 节点间无间距 */
}
/* 树节点基础样式 */
.tree-node {
user-select: none; /* 禁用文本选择 */
}
/* 树节点内容 - GitHub 风格 */
.tree-node-content {
display: flex;
align-items: center;
gap: 0.5rem; /* 图标与文本间距 */
padding: 0.4rem 0.6rem;
border-radius: 6px;
color: #24292f; /* GitHub 文本颜色 */
cursor: pointer;
transition: all 0.2s ease; /* 平滑过渡效果 */
min-width: 0; /* 允许flex子项缩小 */
}
.tree-node-content:hover {
background: #eef2f6;
color: #0969da;
box-shadow: none;
}
/* 树节点选中状态 - GitHub 风格 */
.tree-node-content.selected {
background: #ddf4ff;
color: #0969da;
font-weight: 600;
border: none;
box-shadow: none;
}
/* 树节点图标样式 */
.tree-node-icon {
font-size: 1rem;
opacity: 0.9;
transition: transform 0.2s ease; /* 图标变换动画 */
}
/* 选中状态下的图标放大效果 */
.tree-node-content.selected .tree-node-icon {
transform: scale(1.06); /* 轻微放大 */
}
/* 树节点子级容器 */
.tree-node-children {
margin-top: 0;
}
/* 树节点名称样式 */
.tree-node-name {
font-weight: 600;
flex: 1; /* 占据剩余空间 */
overflow: hidden; /* 隐藏溢出内容 */
text-overflow: ellipsis; /* 显示省略号 */
white-space: nowrap; /* 不换行 */
}
/* 文章数量标识样式 */
.article-count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.1rem 0.25rem;
font-size: 0.7rem;
font-weight: 600;
color: #57606a;
background: transparent;
border-radius: 4px;
transition: all 0.15s ease;
flex-shrink: 0; /* 防止被压缩 */
margin-left: 0.25rem; /* 与文件名保持间距 */
}
/* 悬停状态下的文章数量标识 - GitHub 风格 */
.tree-node-content:hover .article-count-badge {
color: #0969da;
background: transparent;
}
/* 选中状态下的文章数量标识 - GitHub 风格 */
.tree-node-content.selected .article-count-badge {
color: #0969da;
background: transparent;
}
/* 加载状态和错误状态的通用样式 */
.loading,
.error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.7rem;
padding: 2.5rem 1rem;
color: var(--color-muted);
font-size: 0.9rem;
font-family: 'LXGWWenKaiMono', ui-monospace, var(--font-family-base);
}
/* 加载动画 - 旋转圆圈 - GitHub 风格 */
.loading-spinner {
width: 18px;
height: 18px;
border: 2px solid #d0d7de;
border-top-color: #0969da;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ========================================
侧边栏背景遮罩
======================================== */
/* 侧边栏背景遮罩 - 默认隐藏 */
.sidebar-backdrop {
display: none;
}
/* 侧边栏切换按钮 - 固定定位 - GitHub 风格 */
.sidebar-toggle {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 130; /* 最高层级 */
width: 40px;
height: 40px;
padding: 0;
background: #ffffff;
border: 1px solid #d0d7de;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(31, 35, 40, 0.12), 0 8px 24px rgba(66, 74, 83, 0.12);
transition: all 0.2s ease;
touch-action: none; /* 禁用默认触摸行为 */
user-select: none; /* 禁用文本选择 */
color: #24292f;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
}
/* 拖动状态 - GitHub 风格 */
.sidebar-toggle.dragging {
box-shadow: 0 3px 12px rgba(31, 35, 40, 0.15), 0 12px 40px rgba(66, 74, 83, 0.15);
opacity: 0.95;
}
/* ========================================
响应式设计 - 移动端适配
======================================== */
/* 手机屏幕适配 (768px 以下) - GitHub 风格 */
@media (max-width: 768px) {
.sidebar {
width: var(--sidebar-width-mobile, 280px);
padding: 0.75rem;
max-width: calc(100vw - 1rem); /* 防止超出屏幕 */
overflow-x: hidden;
background: #f6f8fa;
border-right: 1px solid #d0d7de;
box-shadow: 2px 0 8px rgba(31, 35, 40, 0.12);
}
/* 移动端关闭状态 */
.sidebar.closed {
transform: translateX(-100%);
}
/* 移动端切换按钮 - GitHub 风格 */
.sidebar-toggle {
width: 40px;
height: 40px;
font-size: 16px;
background: #ffffff;
border: 1px solid #d0d7de;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(31, 35, 40, 0.12), 0 8px 24px rgba(66, 74, 83, 0.12);
}
/* 移动端拖动状态 - GitHub 风格 */
.sidebar-toggle.dragging {
background: #f6f8fa;
border-color: #0969da;
box-shadow: 0 3px 12px rgba(31, 35, 40, 0.15), 0 12px 40px rgba(9, 105, 218, 0.15);
}
/* 移动端悬停效果 */
.sidebar-toggle:active {
transform: scale(0.95);
}
/* 移动端内容区域优化 */
.sidebar-content {
overflow-x: hidden;
width: 100%;
}
/* 移动端目录树优化 */
.directory-tree {
overflow-x: hidden;
width: 100%;
}
/* 移动端标题调整 */
.sidebar-header h2 {
font-size: 1.1rem;
margin: 0.5rem 0;
}
/* 移动端文章数量标识调整 */
.article-count-badge {
min-width: 1.1rem;
height: 1.1rem;
padding: 0.05rem 0.25rem;
font-size: 0.65rem;
margin-left: 0.3rem;
}
}
/* 桌面端适配 (769px 以上) */
@media (min-width: 769px) {
/* 桌面端侧边栏始终可见 */
.sidebar {
position: static; /* 静态定位 */
transform: none; /* 无变换 */
opacity: 1; /* 完全不透明 */
pointer-events: auto; /* 启用鼠标事件 */
}
/* 桌面端隐藏切换按钮 */
.sidebar-toggle {
display: none !important; /* 完全隐藏切换按钮 */
}
}
/* ========================================
??????????(Sidebar.css)
?????????????????????? ======================================== */
/* ========================================
??????
======================================== */
.sidebar {
position: relative;
z-index: 110;
width: var(--sidebar-width);
height: 100%;
min-height: 0;
background: #f6f8fa;
border-right: 1px solid #d0d7de;
display: flex;
flex-direction: column;
padding: 1.5rem 1rem 1.75rem;
color: var(--color-text);
box-shadow: none;
overflow: hidden;
font-family: 'LXGWWenKaiMono', ui-monospace, var(--font-family-base);
/* 只用 GPU 合成层属性,不触发 layout reflow */
}
.sidebar.closed {
width: 0;
padding: 0;
opacity: 0;
pointer-events: none;
border-right: none;
overflow: hidden;
}
/* ========================================
???????? ======================================== */
/* ??????? */
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between; /* ?????????????? */
gap: 0.75rem; /* ???? */
margin-bottom: 1rem;
}
/* ????????*/
.sidebar-header h2 {
margin: 0;
font-size: 1.1rem;
letter-spacing: 0.02em; /* ???? */
font-weight: 600;
color: var(--color-text);
font-family: 'LXGWWenKaiMono', ui-monospace, var(--font-family-base);
flex-shrink: 0; /* ??????*/
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
/* ?????????? */
.sidebar-title-deco {
display: inline-flex;
flex-direction: column;
gap: 2px;
width: 10px;
flex-shrink: 0;
}
.sidebar-title-deco-block {
display: block;
width: 10px;
height: 3px;
border-radius: 1px;
box-shadow: 0 0 0 1px rgba(31, 35, 40, 0.06);
}
.sidebar-title-deco-block--g {
background: linear-gradient(90deg, #40c057, #51cf66);
}
.sidebar-title-deco-block--r {
background: linear-gradient(90deg, #fa5252, #ff6b6b);
}
.sidebar-title-deco-block--b {
background: linear-gradient(90deg, #4c6ef5, #748ffc);
}
/* ========================================
??????
======================================== */
/* ????????- ????GitHub ?? */
.toggle-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
height: 36px;
padding: 0 0.6rem;
background: rgba(255, 255, 255, 0.7);
color: var(--color-muted);
border: 1px solid rgba(208, 215, 222, 0.5);
border-radius: 6px;
font-size: 0.9rem;
line-height: 1;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
box-shadow: 0 1px 3px rgba(31, 35, 40, 0.06);
font-family: 'LXGWWenKaiMono', ui-monospace, var(--font-family-base);
flex-shrink: 0;
margin-left: auto;
opacity: 0.8;
}
/* ?????????- ????GitHub ?? */
.toggle-button:hover {
background: rgba(255, 255, 255, 1);
border-color: var(--color-accent);
color: var(--color-accent);
opacity: 1;
transform: translateX(2px);
box-shadow: 0 2px 6px rgba(9, 105, 218, 0.12);
}
/* ?????????*/
.toggle-button:active {
transform: translateX(0);
box-shadow: 0 1px 3px rgba(9, 105, 218, 0.1);
}
/* ========================================
???????? ======================================== */
/* ???????? */
.sidebar-content {
flex: 1; /* ?????? */
overflow-y: auto; /* ???? */
overflow-x: hidden; /* ?????? */
padding-right: 0.5rem; /* ??????*/
height: 0; /* ?? flex: 1 ????????*/
min-height: 0;
/* scroll-behavior: smooth 在移动端影响手势流畅度,去掉 */
overscroll-behavior: contain;
}
/* ========================================
?????? ======================================== */
/* ??????????*/
.sidebar-empty-hint {
padding: 0.75rem 0.25rem 0;
font-size: 0.85rem;
line-height: 1.5;
color: var(--color-muted);
}
.sidebar-empty-hint p {
margin: 0 0 0.5rem;
}
.sidebar-empty-hint p:first-child {
font-weight: 600;
color: var(--color-text);
}
.sidebar-empty-detail {
font-size: 0.78rem;
line-height: 1.45;
}
.sidebar-empty-detail code {
font-size: 0.85em;
padding: 0.1em 0.35em;
background: rgba(208, 215, 222, 0.45);
border-radius: 4px;
}
/* ??????*/
.directory-tree {
display: flex;
flex-direction: column;
gap: 0.12rem;
}
/* ??????? */
.tree-node {
user-select: none; /* ?????? */
}
/* ?????????? + ????+ ?? */
.folder-shell {
position: relative;
border-radius: 8px;
border: 1px solid var(--folder-border, #d0d7de);
background: var(--folder-bg, #f6f8fa);
overflow: hidden;
box-shadow: 0 1px 2px rgba(31, 35, 40, 0.04);
}
.folder-shell::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: var(--folder-bar, #93c5fd);
border-radius: 8px 0 0 8px;
pointer-events: none;
}
/* ?????? */
.tree-node-content {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.55rem 0.3rem 0.65rem;
border-radius: 6px;
cursor: pointer;
/* 消除移动端 300ms 点击延迟 */
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
/* 背景色过渡在触控设备上跳过,只在鼠标设备保留 */
min-width: 0;
}
/* ??????????????????????/?/????????? */
.tree-node-content--folder {
color: #1b2430;
background: transparent;
}
.tree-node-content--folder:hover {
background: var(--folder-wash, #f1f5f9);
}
.tree-node-content--folder .article-count-badge {
color: var(--folder-muted, #6b7280);
font-weight: 500;
}
.tree-node-content--folder:hover .article-count-badge {
color: #5a6678;
}
/* ????????? */
.tree-node-content--file {
color: #374151;
background: transparent;
}
.tree-node-content--file:hover {
background: #eef2f6;
color: #0969da;
}
.tree-node-content--file:hover .tree-node-icon-wrap--file {
color: #0969da;
}
/* ???????? .md ??? */
.tree-node-content.selected {
background: #ddf4ff;
color: #0969da;
font-weight: 600;
}
.tree-node-content--folder.selected {
background: var(--folder-wash-strong, #e8edf3);
color: #1b2430;
}
.tree-node-content.selected .tree-node-icon-wrap--file {
color: #0969da;
}
.tree-node-icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.tree-node-icon-wrap--file {
color: #64748b;
}
.folder-glyph-svg {
display: block;
flex-shrink: 0;
filter: drop-shadow(0 1px 2px rgba(31, 35, 40, 0.08));
}
.file-glyph-svg {
display: block;
}
.tree-node-children {
position: relative;
margin: 0 0.35rem 0.18rem 0.28rem;
padding-left: 0.2rem;
}
.tree-node-children > .tree-node + .tree-node {
margin-top: 0.12rem;
}
/* ????????*/
.tree-node-name {
font-weight: 600;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-node--file .tree-node-name {
font-weight: 500;
font-size: 0.92em;
}
/* ?????? */
.article-count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.1rem 0.25rem;
font-size: 0.72rem;
font-weight: 600;
color: #6b7280;
background: transparent;
border-radius: 4px;
flex-shrink: 0;
margin-left: 0.25rem;
font-variant-numeric: tabular-nums;
}
.tree-node-content.selected .article-count-badge {
color: #0969da;
}
.tree-node-content--folder.selected .article-count-badge {
color: var(--folder-muted, #6b7280);
}
/* ?????????????? */
.loading,
.error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.7rem;
padding: 2.5rem 1rem;
color: var(--color-muted);
font-size: 0.9rem;
font-family: 'LXGWWenKaiMono', ui-monospace, var(--font-family-base);
}
/* ???? - ???? - GitHub ?? */
.loading-spinner {
width: 18px;
height: 18px;
border: 2px solid #d0d7de;
border-top-color: #0969da;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ========================================
???????? ======================================== */
/* ????????- ???? - GitHub ?? */
.sidebar-toggle {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 130; /* ?????*/
width: 40px;
height: 40px;
padding: 0;
background: #ffffff;
border: 1px solid #d0d7de;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(31, 35, 40, 0.12), 0 8px 24px rgba(66, 74, 83, 0.12);
touch-action: none; /* ???????? */
user-select: none; /* ?????? */
color: #24292f;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
}
/* ?????- GitHub ?? */
.sidebar-toggle.dragging {
box-shadow: 0 3px 12px rgba(31, 35, 40, 0.15), 0 12px 40px rgba(66, 74, 83, 0.15);
opacity: 0.95;
}
/* ========================================
??????- ?????
======================================== */
/* 移动端:侧栏为 fixed 抽屉,不占 flex 宽度,正文始终全宽 */
@media (max-width: 768px) {
.sidebar {
position: fixed;
top: var(--site-header-height, 48px);
left: 0;
bottom: 0;
width: min(88vw, 300px);
max-width: min(88vw, 300px);
height: auto;
min-height: calc(100dvh - var(--site-header-height, 48px));
flex: none;
z-index: 200;
margin: 0 !important;
padding: 0.75rem;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
background: #f6f8fa;
border-right: 1px solid #d0d7de;
box-shadow: 4px 0 16px rgba(31, 35, 40, 0.12);
opacity: 1 !important;
/* 仅 transformGPU 合成,不触发 reflow */
}
.sidebar.open {
transform: translateX(0);
pointer-events: auto;
}
.sidebar.closed {
transform: translateX(-100%);
pointer-events: none;
}
/* ????????- GitHub ?? */
.sidebar-toggle {
width: 40px;
height: 40px;
font-size: 16px;
background: #ffffff;
border: 1px solid #d0d7de;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(31, 35, 40, 0.12), 0 8px 24px rgba(66, 74, 83, 0.12);
}
/* ????????- GitHub ?? */
.sidebar-toggle.dragging {
background: #f6f8fa;
border-color: #0969da;
box-shadow: 0 3px 12px rgba(31, 35, 40, 0.15), 0 12px 40px rgba(9, 105, 218, 0.15);
}
/* ????????*/
.sidebar-toggle:active {
transform: scale(0.95);
}
/* ??????????*/
.sidebar-content {
overflow-x: hidden;
width: 100%;
}
/* ???????? */
.directory-tree {
overflow-x: hidden;
width: 100%;
}
/* ????????*/
.sidebar-header h2 {
font-size: 1.1rem;
margin: 0.5rem 0;
}
/* ????????????*/
.article-count-badge {
min-width: 1.1rem;
height: 1.1rem;
padding: 0.05rem 0.25rem;
font-size: 0.65rem;
margin-left: 0.3rem;
}
}
/* ????? (769px ??) */
@media (min-width: 769px) {
.sidebar {
position: static;
top: auto;
left: auto;
bottom: auto;
z-index: 110;
height: 100%;
min-height: 0;
box-shadow: none;
margin-left: 0;
}
/* 桌面 open恢复到原始位置和占位 */
.sidebar.open {
transform: translateX(0);
margin-left: 0;
opacity: 1;
pointer-events: auto;
}
/* ??????????*/
.sidebar-toggle {
display: none !important; /* ???????? */
}
}

View File

@@ -3,7 +3,189 @@ import { useApp } from '../context/AppContext';
import { NODE_TYPES } from '../utils/fileUtils';
import './Sidebar.css';
function TreeNode({ node, level = 0 }) {
function pathHash32(path) {
let h = 0;
for (let i = 0; i < path.length; i += 1) {
h = Math.imul(31, h) + path.charCodeAt(i);
}
return h >>> 0;
}
/** 可见顺序:红→橙→黄→绿→青→蓝→紫→浅黑→浅白,循环;均为浅色粉彩 */
const FOLDER_PALETTE_LIGHT = [
{
bar: 'hsl(355 58% 64%)',
bg: 'hsl(355 48% 97.4%)',
border: 'hsl(355 38% 90%)',
iconTop: 'hsl(355 62% 88%)',
iconBottom: 'hsl(355 58% 72%)',
wash: 'hsl(355 42% 95%)',
washStrong: 'hsl(355 38% 92%)',
muted: 'hsl(350 12% 46%)',
},
{
bar: 'hsl(28 78% 62%)',
bg: 'hsl(32 62% 97.2%)',
border: 'hsl(30 48% 89%)',
iconTop: 'hsl(32 72% 87%)',
iconBottom: 'hsl(28 75% 70%)',
wash: 'hsl(32 50% 94.5%)',
washStrong: 'hsl(30 45% 91.5%)',
muted: 'hsl(28 14% 45%)',
},
{
bar: 'hsl(48 72% 58%)',
bg: 'hsl(50 58% 97.6%)',
border: 'hsl(48 42% 89%)',
iconTop: 'hsl(52 68% 88%)',
iconBottom: 'hsl(48 70% 68%)',
wash: 'hsl(50 48% 95%)',
washStrong: 'hsl(48 40% 92%)',
muted: 'hsl(45 12% 44%)',
},
{
bar: 'hsl(145 52% 52%)',
bg: 'hsl(142 45% 97.3%)',
border: 'hsl(145 36% 88%)',
iconTop: 'hsl(142 48% 86%)',
iconBottom: 'hsl(145 50% 66%)',
wash: 'hsl(142 38% 94.5%)',
washStrong: 'hsl(145 32% 91%)',
muted: 'hsl(145 10% 43%)',
},
{
bar: 'hsl(178 55% 48%)',
bg: 'hsl(175 42% 97.4%)',
border: 'hsl(178 34% 88%)',
iconTop: 'hsl(175 46% 85%)',
iconBottom: 'hsl(178 50% 62%)',
wash: 'hsl(175 36% 94.5%)',
washStrong: 'hsl(178 30% 91%)',
muted: 'hsl(178 10% 42%)',
},
{
bar: 'hsl(218 62% 62%)',
bg: 'hsl(215 50% 97.5%)',
border: 'hsl(218 40% 89%)',
iconTop: 'hsl(215 58% 87%)',
iconBottom: 'hsl(218 62% 68%)',
wash: 'hsl(215 42% 95%)',
washStrong: 'hsl(218 36% 92%)',
muted: 'hsl(218 12% 44%)',
},
{
bar: 'hsl(268 52% 62%)',
bg: 'hsl(265 45% 97.6%)',
border: 'hsl(268 36% 90%)',
iconTop: 'hsl(265 50% 88%)',
iconBottom: 'hsl(268 55% 70%)',
wash: 'hsl(265 38% 95%)',
washStrong: 'hsl(268 32% 92%)',
muted: 'hsl(265 12% 44%)',
},
{
bar: 'hsl(220 14% 46%)',
bg: 'hsl(220 10% 95.8%)',
border: 'hsl(220 8% 87%)',
iconTop: 'hsl(220 12% 84%)',
iconBottom: 'hsl(220 14% 60%)',
wash: 'hsl(220 8% 93%)',
washStrong: 'hsl(220 6% 90%)',
muted: 'hsl(220 10% 42%)',
},
{
bar: 'hsl(210 12% 74%)',
bg: 'hsl(42 28% 98.4%)',
border: 'hsl(40 18% 91%)',
iconTop: 'hsl(45 22% 94%)',
iconBottom: 'hsl(210 10% 78%)',
wash: 'hsl(42 22% 96.2%)',
washStrong: 'hsl(40 18% 94%)',
muted: 'hsl(220 8% 46%)',
},
];
const PALETTE_LEN = FOLDER_PALETTE_LIGHT.length;
function folderThemeForSlot(slot) {
return FOLDER_PALETTE_LIGHT[((slot % PALETTE_LEN) + PALETTE_LEN) % PALETTE_LEN];
}
/**
* 按侧边栏当前可见顺序(深度优先:列出文件夹 → 若展开则进入子级)分配 0,1,2… 再对 9 取模。
*/
function buildVisibleFolderSlotMap(nodes) {
const map = new Map();
let seq = 0;
function walk(list) {
if (!list) return;
for (const n of list) {
if (n.type !== NODE_TYPES.FOLDER) continue;
map.set(n.path, seq % PALETTE_LEN);
seq += 1;
if (n.isExpanded && n.children?.length) {
walk(n.children);
}
}
}
walk(nodes);
return map;
}
function folderGradientId(path) {
return `fol-${pathHash32(path).toString(36)}`;
}
/** 填充式文件夹 SVG渐变填充展开时使用略开的轮廓 */
function FolderGlyph({ path, expanded }) {
const gid = folderGradientId(path);
/* Material Design Icons 风格viewBox 0 0 24 24 */
const closedPath =
'M10 4H4C2.89 4 2 4.89 2 6v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8c0-1.11-.9-2-2-2h-8l-2-2z';
const openPath =
'M19 20H4C2.89 20 2 19.1 2 18V8C2 6.89 2.89 6 4 6H10L12 4H19A2 2 0 0 1 21 6V18C21 19.1 20.1 20 19 20Z';
return (
<svg
className="folder-glyph-svg"
width="20"
height="16"
viewBox="0 0 24 24"
aria-hidden
>
<defs>
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--folder-icon-top)" />
<stop offset="100%" stopColor="var(--folder-icon-bottom)" />
</linearGradient>
</defs>
<path fill={`url(#${gid})`} d={expanded ? openPath : closedPath} />
</svg>
);
}
function FileGlyph() {
return (
<svg className="file-glyph-svg" width="18" height="18" viewBox="0 0 24 24" aria-hidden>
<path
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"
/>
<path
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
d="M14 2v6h6"
/>
</svg>
);
}
function TreeNode({ node, level = 0, folderSlotByPath }) {
const { selectFile, toggleNodeExpansion, currentFile } = useApp();
// 计算当前目录下的直接文章数量
@@ -26,50 +208,89 @@ function TreeNode({ node, level = 0 }) {
const isSelected = currentFile === node.path;
const hasChildren = node.children && node.children.length > 0;
const paddingLeft = `${level * 18 + 14}px`;
const contentClass = isSelected ? 'tree-node-content selected' : 'tree-node-content';
const folderIconClass = `tree-node-icon${node.isExpanded ? ' expanded' : ''}`;
const paddingLeft = `${level * 10 + 10}px`;
const contentClass =
node.type === NODE_TYPES.FOLDER
? `tree-node-content tree-node-content--folder${isSelected ? ' selected' : ''}`
: `tree-node-content tree-node-content--file${isSelected ? ' selected' : ''}`;
const folderSlot =
node.type === NODE_TYPES.FOLDER ? folderSlotByPath.get(node.path) ?? 0 : null;
const folderTheme =
node.type === NODE_TYPES.FOLDER ? folderThemeForSlot(folderSlot) : null;
const folderShellStyle =
folderTheme &&
({
'--folder-bar': folderTheme.bar,
'--folder-bg': folderTheme.bg,
'--folder-border': folderTheme.border,
'--folder-icon-top': folderTheme.iconTop,
'--folder-icon-bottom': folderTheme.iconBottom,
'--folder-wash': folderTheme.wash,
'--folder-wash-strong': folderTheme.washStrong,
'--folder-muted': folderTheme.muted,
});
const fileRow = (
<div
className={contentClass}
style={{ paddingLeft }}
onClick={handleClick}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleClick();
}
}}
>
{node.type === NODE_TYPES.FOLDER && (
<span className="tree-node-icon-wrap tree-node-icon-wrap--folder" aria-hidden>
<FolderGlyph path={node.path} expanded={Boolean(node.isExpanded && hasChildren)} />
</span>
)}
{node.type === NODE_TYPES.FILE && (
<span className="tree-node-icon-wrap tree-node-icon-wrap--file" aria-hidden>
<FileGlyph />
</span>
)}
<span className="tree-node-name">
{node.type === NODE_TYPES.FILE && node.name.endsWith('.md')
? node.name.slice(0, -3)
: node.name}
</span>
{node.type === NODE_TYPES.FOLDER && articleCount > 0 && (
<span className="article-count-badge">[{articleCount}]</span>
)}
</div>
);
if (node.type === NODE_TYPES.FOLDER) {
return (
<div className="tree-node tree-node--folder">
<div className="folder-shell" style={folderShellStyle}>
{fileRow}
{node.isExpanded && hasChildren && (
<div className="tree-node-children">
{node.children.map((child) => (
<TreeNode
key={child.path}
node={child}
level={level + 1}
folderSlotByPath={folderSlotByPath}
/>
))}
</div>
)}
</div>
</div>
);
}
return (
<div className="tree-node">
<div
className={contentClass}
style={{ paddingLeft }}
onClick={handleClick}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleClick();
}
}}
>
{node.type === NODE_TYPES.FOLDER && (
<span className={folderIconClass} aria-hidden>
{hasChildren ? (node.isExpanded ? '📂' : '📁') : '📁'}
</span>
)}
{node.type === NODE_TYPES.FILE && (
<span className="tree-node-icon" aria-hidden>📄</span>
)}
<span className="tree-node-name">
{node.type === NODE_TYPES.FILE && node.name.endsWith('.md')
? node.name.slice(0, -3)
: node.name}
</span>
{node.type === NODE_TYPES.FOLDER && articleCount > 0 && (
<span className="article-count-badge">[{articleCount}]</span>
)}
</div>
{node.type === NODE_TYPES.FOLDER && node.isExpanded && hasChildren && (
<div className="tree-node-children">
{node.children.map((child) => (
<TreeNode key={child.path} node={child} level={level + 1} />
))}
</div>
)}
<div className="tree-node tree-node--file">
{fileRow}
</div>
);
}
@@ -77,11 +298,23 @@ function TreeNode({ node, level = 0 }) {
export default function Sidebar() {
const { directoryTree, isLoading, error, sidebarOpen, toggleSidebar } = useApp();
const folderSlotByPath = useMemo(
() => buildVisibleFolderSlotMap(directoryTree),
[directoryTree]
);
return (
<>
<aside className={sidebarOpen ? 'sidebar open' : 'sidebar closed'} aria-hidden={!sidebarOpen}>
<div className="sidebar-header">
<h2>📚 文章目录</h2>
<h2>
<span className="sidebar-title-deco" aria-hidden>
<span className="sidebar-title-deco-block sidebar-title-deco-block--g" />
<span className="sidebar-title-deco-block sidebar-title-deco-block--r" />
<span className="sidebar-title-deco-block sidebar-title-deco-block--b" />
</span>
文章目录
</h2>
<button
type="button"
onClick={toggleSidebar}
@@ -107,13 +340,34 @@ export default function Sidebar() {
</div>
)}
{!isLoading && !error && (
{!isLoading && !error && directoryTree.length > 0 && (
<div className="directory-tree">
{directoryTree.map((node) => (
<TreeNode key={node.path} node={node} />
<TreeNode key={node.path} node={node} folderSlotByPath={folderSlotByPath} />
))}
</div>
)}
{!isLoading && !error && directoryTree.length === 0 && (
<div className="sidebar-empty-hint">
<p>暂无文章目录</p>
<p className="sidebar-empty-detail">
接口已返回空列表请在后端确认笔记目录已挂载且路径正确例如将整个
<code>data</code>
挂到容器的
<code>/app/mengyanote</code>
笔记应在
<code>mengyanote/mengyanote/</code>
或查看
<code>/api/health</code>
中的
<code>markdown_root</code>
<code>markdown_root_entry_count</code>
</p>
</div>
)}
</div>
</aside>
</>

View File

@@ -0,0 +1,48 @@
.site-header {
display: flex;
align-items: center;
gap: 0.65rem;
flex-shrink: 0;
height: var(--site-header-height, 48px);
padding: 0 0.75rem 0 0.65rem;
background: #f6f8fa;
border-bottom: 1px solid var(--color-border);
z-index: 120;
}
.site-logo-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
margin: 0;
background: transparent;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s ease;
}
.site-logo-button:hover {
background: rgba(9, 105, 218, 0.08);
}
.site-logo-button:focus {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.site-logo-img {
display: block;
width: 36px;
height: 36px;
border-radius: 6px;
object-fit: contain;
}
.site-title {
font-size: 1.05rem;
font-weight: 600;
color: var(--color-text);
letter-spacing: 0.02em;
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { useAdmin } from '../context/AdminContext';
import './SiteHeader.css';
export default function SiteHeader() {
const { handleLogoClick } = useAdmin();
const logoSrc = `${import.meta.env.BASE_URL}logo.png`;
return (
<header className="site-header">
<button
type="button"
className="site-logo-button"
onClick={handleLogoClick}
aria-label="萌芽笔记"
title="萌芽笔记"
>
<img src={logoSrc} alt="" className="site-logo-img" width={36} height={36} />
</button>
<span className="site-title">萌芽笔记</span>
</header>
);
}