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

@@ -1,2 +1,4 @@
# 开发环境配置 - 用于本地开发 (npm run dev)
VITE_API_BASE=http://192.168.1.233:2424
# 开发环境 (npm run dev)
# 留空:使用相对路径 /api/*,由 vite.config.js 代理到本机后端(见 server.proxy
# 若需直连某台后端,可改为例如 http://127.0.0.1:8000此时不走代理需自行处理 CORS
VITE_API_BASE=

View File

@@ -1,28 +0,0 @@
# 萌芽笔记 PWA 说明
本前端已配置为 **PWA渐进式 Web 应用)**,支持:
- **安装到桌面/主屏**:浏览器中可“安装应用”,像原生应用一样打开
- **离线可用**:静态资源与部分 API 会被缓存,弱网/离线时可继续使用
- **独立窗口**:安装后以独立窗口运行(无浏览器地址栏)
## 图标
请将应用图标放在 **`public/logo.png`**。建议尺寸:
- 至少 **192×192** 像素(用于主屏图标)
- 若有 **512×512** 像素,可同时用于启动画面等
若未放置 `logo.png`PWA 仍可正常工作,但安装图标可能为默认或空白。
## 构建与部署
```bash
npm run build
```
构建产物在 `dist/`,部署后通过 **HTTPS** 访问即可使用 PWA 能力(本地预览可用 `npm run preview`)。
## 开发时
`vite.config.js` 中已开启 `devOptions: { enabled: true }`,开发时也会注册 Service Worker便于调试离线与安装行为。不需要时可改为 `enabled: false`

View File

@@ -2,10 +2,11 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/logo.png" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#1a1a2e" />
<meta name="description" content="萌芽笔记 - Markdown 笔记 PWA 应用" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="萌芽笔记" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -1,218 +1,267 @@
/* ========================================
主应用样式文件 (App.css)
用途:定义全局主题变量、应用布局和响应式设计
======================================== */
/* ========================================
CSS 自定义属性 (主题变量)
======================================== */
/* CSS 自定义属性 (主题变量) - GitHub 主题 */
:root {
/* GitHub 颜色系统 */
--color-bg: #ffffff; /* 主背景色 - 纯白 */
--color-surface: #f6f8fa; /* 表面背景色 - GitHub 浅灰 */
--color-surface-alt: #ffffff; /* 替代表面色 - 纯白 */
--color-border: #d0d7de; /* 边框颜色 - GitHub 边框灰 */
--color-text: #24292f; /* 主文本颜色 - GitHub 黑 */
--color-muted: #57606a; /* 次要文本颜色 - GitHub 灰 */
--color-accent: #0969da; /* 强调色 - GitHub 蓝 */
--color-accent-soft: rgba(9, 105, 218, 0.1); /* 柔和强调色 - 半透明蓝色 */
--color-danger: #cf222e; /* 危险/错误色 - GitHub 红 */
--color-success: #2da44e; /* 成功色 - GitHub 绿 */
/* 布局尺寸 - 定义组件的尺寸规范 */
--sidebar-width: clamp(250px, 20vw, 300px); /* 桌面端侧边栏宽度 - 响应式 */
--sidebar-width-mobile: clamp(250px, 35vw, 280px); /* 移动端侧边栏宽度 - 响应式 */
/* 视觉效果 - GitHub 风格 */
--shadow-soft: 0 1px 3px rgba(31, 35, 40, 0.12), 0 8px 24px rgba(66, 74, 83, 0.12);
--radius-md: 6px; /* 中等圆角半径 - GitHub 标准 */
/* 字体系统 - LXGWWenKaiMono 字体族 */
--font-family-base: 'LXGWWenKaiMono', ui-monospace, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
}
/* ========================================
全局样式重置
======================================== */
/* 盒模型重置 - 确保所有元素使用 border-box */
* {
box-sizing: border-box;
font-family: 'LXGWWenKaiMono', ui-monospace, var(--font-family-base);
}
/* ========================================
页面基础布局
======================================== */
/* HTML 和 Body 基础样式 - GitHub 风格 */
html,
body {
margin: 0;
padding: 0;
min-height: 100vh;
/* GitHub 风格纯色背景 */
background: #ffffff;
color: var(--color-text);
font-family: var(--font-family-base);
overflow-x: hidden; /* 防止水平滚动 */
width: 100%;
max-width: 100vw;
/* 全局隐藏滚动条 - 提供更清洁的视觉体验 */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
/* 全局隐藏 WebKit 浏览器的滚动条 */
html::-webkit-scrollbar,
body::-webkit-scrollbar {
display: none;
}
/* ========================================
浮动展开按钮(侧边栏关闭时显示)
======================================== */
.floating-open-button {
position: fixed;
top: 10px;
left: 10px;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
/* 柔和的 GitHub 风格 */
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
color: var(--color-muted);
border: 1px solid rgba(208, 215, 222, 0.6);
border-radius: 8px;
font-size: 1.2rem;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 8px rgba(31, 35, 40, 0.08);
font-family: 'LXGWWenKaiMono', ui-monospace, var(--font-family-base);
opacity: 0.8;
}
.floating-open-button:hover {
background: rgba(255, 255, 255, 1);
color: var(--color-accent);
border-color: var(--color-accent);
opacity: 1;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(9, 105, 218, 0.15);
}
.floating-open-button:active {
transform: translateY(0);
box-shadow: 0 2px 6px rgba(9, 105, 218, 0.12);
}
/* ========================================
应用根容器布局
======================================== */
/* React 根容器 */
#root {
min-height: 100vh;
display: flex;
padding: 0;
width: 100%;
max-width: 100vw;
overflow-x: hidden;
}
/* 主应用容器 - GitHub 风格 */
.app {
flex: 1;
display: flex;
min-height: 100vh;
border-radius: 0;
/* GitHub 风格纯色背景 */
background: #ffffff;
border: none;
box-shadow: none;
overflow: hidden;
position: relative;
width: 100%;
max-width: 100vw;
}
/* ========================================
全局交互样式
======================================== */
/* ========================================
全局交互样式
======================================== */
/* 文本选择样式 - GitHub 风格 */
body ::selection {
background-color: rgba(9, 105, 218, 0.15);
color: var(--color-text);
}
/* 全局隐藏所有滚动条 - 统一的滚动条隐藏策略 */
::-webkit-scrollbar {
display: none;
}
* {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
/* 焦点样式 - GitHub 风格 */
button:focus,
a:focus {
outline: 2px solid #0969da;
outline-offset: 2px;
}
/* ========================================
响应式设计 - 移动端适配
======================================== */
/* 手机屏幕适配 (768px 以下) */
@media (max-width: 768px) {
/* 手机端字体放大 - 提高移动端可读性 */
html {
font-size: 150%; /* 基础字体大小放大 1.5 倍 */
}
/* 移动端侧边栏宽度变量 */
:root {
--sidebar-width-mobile: 280px;
}
/* 移动端页面布局优化 */
html, body {
overflow-x: hidden; /* 严格防止水平滚动 */
width: 100%;
max-width: 100vw;
}
/* 移动端根容器 */
#root {
padding: 0;
width: 100%;
max-width: 100vw;
overflow-x: hidden;
}
/* 移动端应用容器 */
.app {
min-height: 100vh;
border-radius: 0; /* 移动端去除圆角 */
width: 100%;
max-width: 100vw;
overflow-x: hidden;
flex-direction: row; /* 保持水平布局 */
}
}
/* ========================================
主应用样式文件 (App.css)
用途:定义全局主题变量、应用布局和响应式设计
======================================== */
/* ========================================
CSS 自定义属性 (主题变量)
======================================== */
/* CSS 自定义属性 (主题变量) - GitHub 主题 */
:root {
/* GitHub 颜色系统 */
--color-bg: #ffffff; /* 主背景色 - 纯白 */
--color-surface: #f6f8fa; /* 表面背景色 - GitHub 浅灰 */
--color-surface-alt: #ffffff; /* 替代表面色 - 纯白 */
--color-border: #d0d7de; /* 边框颜色 - GitHub 边框灰 */
--color-text: #24292f; /* 主文本颜色 - GitHub 黑 */
--color-muted: #57606a; /* 次要文本颜色 - GitHub 灰 */
--color-accent: #0969da; /* 强调色 - GitHub 蓝 */
--color-accent-soft: rgba(9, 105, 218, 0.1); /* 柔和强调色 - 半透明蓝色 */
--color-danger: #cf222e; /* 危险/错误色 - GitHub 红 */
--color-success: #2da44e; /* 成功色 - GitHub 绿 */
/* 布局尺寸 - 定义组件的尺寸规范 */
--site-header-height: 48px;
--sidebar-width: clamp(250px, 20vw, 300px); /* 桌面端侧边栏宽度 - 响应式 */
--sidebar-width-mobile: clamp(250px, 35vw, 280px); /* 移动端侧边栏宽度 - 响应式 */
/* 视觉效果 - GitHub 风格 */
--shadow-soft: 0 1px 3px rgba(31, 35, 40, 0.12), 0 8px 24px rgba(66, 74, 83, 0.12);
--radius-md: 6px; /* 中等圆角半径 - GitHub 标准 */
/* 字体系统 - LXGWWenKaiMono 字体族 */
--font-family-base: 'LXGWWenKaiMono', ui-monospace, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
}
/* ========================================
全局样式重置
======================================== */
/* 盒模型重置 - 确保所有元素使用 border-box */
* {
box-sizing: border-box;
font-family: 'LXGWWenKaiMono', ui-monospace, var(--font-family-base);
}
/* ========================================
页面基础布局
======================================== */
/* HTML 和 Body 基础样式 - GitHub 风格 */
html,
body {
margin: 0;
padding: 0;
/* 锁定为视口高度,禁止 body 整页滚动,让滚动只在各子区域内独立发生 */
height: 100%;
overflow: hidden;
background: #ffffff;
color: var(--color-text);
font-family: var(--font-family-base);
width: 100%;
max-width: 100vw;
scrollbar-width: none;
-ms-overflow-style: none;
}
/* 全局隐藏 WebKit 浏览器的滚动条 */
html::-webkit-scrollbar,
body::-webkit-scrollbar {
display: none;
}
/* ========================================
浮动展开按钮(侧边栏关闭时显示)
======================================== */
.floating-open-button {
position: fixed;
top: calc(var(--site-header-height, 48px) + 10px);
left: 10px;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
background: rgba(255, 255, 255, 0.96);
/* backdrop-filter 移除:移动端 GPU 开销大,改用纯色背景 */
color: var(--color-muted);
border: 1px solid rgba(208, 215, 222, 0.7);
border-radius: 8px;
font-size: 1.2rem;
cursor: pointer;
/* 消除移动端 300ms 点击延迟 */
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
box-shadow: 0 2px 8px rgba(31, 35, 40, 0.08);
font-family: 'LXGWWenKaiMono', ui-monospace, var(--font-family-base);
}
.floating-open-button:hover {
background: rgba(255, 255, 255, 1);
color: var(--color-accent);
border-color: var(--color-accent);
opacity: 1;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(9, 105, 218, 0.15);
}
.floating-open-button:active {
transform: translateY(0);
box-shadow: 0 2px 6px rgba(9, 105, 218, 0.12);
}
/* ========================================
应用根容器布局
======================================== */
/* React 根容器 */
#root {
height: 100%;
display: flex;
padding: 0;
width: 100%;
max-width: 100vw;
overflow: hidden;
}
/* 主应用容器 - GitHub 风格 */
.app {
flex: 1;
display: flex;
flex-direction: column;
height: 100%; /* 精确填满 #root不允许撑高 */
border-radius: 0;
background: #ffffff;
border: none;
box-shadow: none;
overflow: hidden;
position: relative;
width: 100%;
max-width: 100vw;
}
/* 顶栏下方的侧栏与正文区域 */
.app-main {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
position: relative;
}
/* ========================================
全局交互样式
======================================== */
/* ========================================
全局交互样式
======================================== */
/* 文本选择样式 - GitHub 风格 */
body ::selection {
background-color: rgba(9, 105, 218, 0.15);
color: var(--color-text);
}
/* 全局隐藏所有滚动条 - 统一的滚动条隐藏策略 */
::-webkit-scrollbar {
display: none;
}
* {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
/* 焦点样式 - GitHub 风格 */
button:focus,
a:focus {
outline: 2px solid #0969da;
outline-offset: 2px;
}
/* ========================================
响应式设计 - 移动端适配
======================================== */
/* 手机屏幕适配 (768px 以下) */
@media (max-width: 768px) {
/* 与桌面共用 16px 基准,避免 rem 放大导致侧栏/正文比例失控 */
html {
font-size: 100%;
}
:root {
--sidebar-width-mobile: min(88vw, 300px);
}
html, body {
overflow-x: hidden;
width: 100%;
max-width: 100vw;
}
#root {
padding: 0;
width: 100%;
max-width: 100vw;
overflow-x: hidden;
}
.app {
height: 100%;
border-radius: 0;
width: 100%;
max-width: 100vw;
overflow: hidden;
flex-direction: column;
}
.app-main {
flex: 1;
min-width: 0;
width: 100%;
}
.floating-open-button {
z-index: 210;
}
}
/* 侧栏打开时的遮罩:仅移动端 */
.sidebar-backdrop {
display: none;
}
to {
opacity: 1;
}
}
@media (max-width: 768px) {
.sidebar-backdrop {
display: block;
position: fixed;
top: var(--site-header-height, 48px);
left: 0;
right: 0;
bottom: 0;
z-index: 190;
margin: 0;
padding: 0;
border: none;
background: rgba(15, 23, 42, 0.32);
/* backdrop-filter 移除:低端机 GPU 开销大 */
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
}
}

View File

@@ -1,26 +1,50 @@
import React from 'react';
import { AppProvider, useApp } from './context/AppContext';
import { AdminProvider } from './context/AdminContext';
import Sidebar from './components/Sidebar';
import MarkdownRenderer from './components/MarkdownRenderer';
import SiteHeader from './components/SiteHeader';
import AdminTokenModal from './components/AdminTokenModal';
import AdminPanel from './components/AdminPanel';
import './App.css';
function AdminBridge({ children }) {
const { loadDirectoryTree } = useApp();
return (
<AdminProvider onTreeShouldReload={loadDirectoryTree}>{children}</AdminProvider>
);
}
function AppContent() {
const { sidebarOpen, toggleSidebar } = useApp();
return (
<div className="app">
{!sidebarOpen && (
<SiteHeader />
{sidebarOpen && (
<button
className="floating-open-button"
type="button"
className="sidebar-backdrop"
onClick={toggleSidebar}
aria-label="展开目录"
title="展开目录"
>
</button>
aria-label="关闭目录"
/>
)}
<Sidebar />
<MarkdownRenderer />
<div className="app-main">
{!sidebarOpen && (
<button
className="floating-open-button"
onClick={toggleSidebar}
aria-label="展开目录"
title="展开目录"
>
</button>
)}
<Sidebar />
<MarkdownRenderer />
</div>
<AdminTokenModal />
<AdminPanel />
</div>
);
}
@@ -28,7 +52,9 @@ function AppContent() {
function App() {
return (
<AppProvider>
<AppContent />
<AdminBridge>
<AppContent />
</AdminBridge>
</AppProvider>
);
}

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>
);
}

View File

@@ -0,0 +1,116 @@
/* eslint-disable react-refresh/only-export-components */
import React, { createContext, useCallback, useContext, useRef, useState } from 'react';
import {
adminLogin as adminLoginApi,
fetchIgnoreList,
} from '../utils/fileUtils';
const SESSION_KEY = 'mengyanote_admin_token';
const LOGO_CLICK_WINDOW_MS = 2200;
const LOGO_CLICKS_NEEDED = 5;
const AdminContext = createContext(null);
export function AdminProvider({ children, onTreeShouldReload }) {
const [tokenModalOpen, setTokenModalOpen] = useState(false);
const [adminPanelOpen, setAdminPanelOpen] = useState(false);
const [tokenSubmitError, setTokenSubmitError] = useState(null);
const logoClicksRef = useRef({ count: 0, timer: null });
const tryOpenAdminWithSession = useCallback(async () => {
const token = sessionStorage.getItem(SESSION_KEY);
if (!token) return false;
try {
await fetchIgnoreList(token);
setAdminPanelOpen(true);
return true;
} catch {
sessionStorage.removeItem(SESSION_KEY);
return false;
}
}, []);
const handleLogoClick = useCallback(() => {
const r = logoClicksRef.current;
if (r.timer) {
clearTimeout(r.timer);
r.timer = null;
}
r.count += 1;
if (r.count >= LOGO_CLICKS_NEEDED) {
r.count = 0;
(async () => {
const opened = await tryOpenAdminWithSession();
if (!opened) {
setTokenSubmitError(null);
setTokenModalOpen(true);
}
})();
return;
}
r.timer = setTimeout(() => {
r.count = 0;
r.timer = null;
}, LOGO_CLICK_WINDOW_MS);
}, [tryOpenAdminWithSession]);
const submitToken = useCallback(
async (token) => {
setTokenSubmitError(null);
try {
await adminLoginApi(token);
sessionStorage.setItem(SESSION_KEY, token);
setTokenModalOpen(false);
setAdminPanelOpen(true);
} catch (e) {
setTokenSubmitError(e.message || '验证失败');
}
},
[]
);
const closeTokenModal = useCallback(() => {
setTokenModalOpen(false);
setTokenSubmitError(null);
}, []);
const closeAdminPanel = useCallback(() => {
setAdminPanelOpen(false);
}, []);
const logoutAdmin = useCallback(() => {
sessionStorage.removeItem(SESSION_KEY);
setAdminPanelOpen(false);
}, []);
const getStoredToken = useCallback(() => sessionStorage.getItem(SESSION_KEY), []);
const notifyIgnoreListChanged = useCallback(() => {
if (typeof onTreeShouldReload === 'function') {
onTreeShouldReload();
}
}, [onTreeShouldReload]);
const value = {
tokenModalOpen,
adminPanelOpen,
tokenSubmitError,
handleLogoClick,
submitToken,
closeTokenModal,
closeAdminPanel,
logoutAdmin,
getStoredToken,
notifyIgnoreListChanged,
};
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>;
}
export function useAdmin() {
const ctx = useContext(AdminContext);
if (!ctx) {
throw new Error('useAdmin must be used within AdminProvider');
}
return ctx;
}

View File

@@ -1,8 +1,32 @@
/* eslint-disable react-refresh/only-export-components */
/* eslint-disable react-hooks/exhaustive-deps */
import React, { createContext, useContext, useReducer, useEffect } from 'react';
import React, { createContext, useContext, useReducer, useEffect, useRef } from 'react';
import { readDirectoryTree, readMarkdownFile, NODE_TYPES } from '../utils/fileUtils';
// 将文件相对路径转换为浏览器 URL/note/<编码后路径>
function filePathToUrl(filePath) {
if (!filePath) return '/';
const encoded = filePath.split('/').map(encodeURIComponent).join('/');
return `/note/${encoded}`;
}
// 从 pathname 中解析文件相对路径(/note/... → 相对路径)
function urlToFilePath(pathname) {
if (!pathname.startsWith('/note/')) return null;
const raw = pathname.slice('/note/'.length);
if (!raw) return null;
try {
return raw.split('/').map(decodeURIComponent).join('/');
} catch {
return null;
}
}
function getInitialSidebarOpen() {
if (typeof window === 'undefined') return true;
return window.matchMedia('(min-width: 769px)').matches;
}
const initialState = {
directoryTree: [],
currentFile: null,
@@ -10,7 +34,7 @@ const initialState = {
currentMetadata: null, // 添加元数据
isLoading: false,
error: null,
sidebarOpen: true,
sidebarOpen: getInitialSidebarOpen(),
};
export const ACTIONS = {
@@ -162,13 +186,23 @@ const AppContext = createContext();
export function AppProvider({ children }) {
const [state, dispatch] = useReducer(appReducer, initialState);
// 用 ref 避免在事件监听器闭包中捕获过期的 state
const treeRef = useRef([]);
const selectFile = async (filePath) => {
const selectFile = async (filePath, { pushUrl = true } = {}) => {
try {
console.log('[MengyaNote] 选择文件:', filePath);
dispatch({ type: ACTIONS.SET_LOADING, payload: true });
dispatch({ type: ACTIONS.SET_CURRENT_FILE, payload: filePath });
// 同步更新浏览器地址栏(不刷新页面)
if (pushUrl) {
const newUrl = filePathToUrl(filePath);
if (window.location.pathname !== newUrl) {
window.history.pushState({ filePath }, '', newUrl);
}
}
const result = await readMarkdownFile(filePath);
console.log('[MengyaNote] 文件内容加载成功');
@@ -180,25 +214,44 @@ export function AppProvider({ children }) {
}
};
// 根据文件路径展开目录树侧边栏对应节点
const expandForPath = (filePath) => {
if (!filePath || treeRef.current.length === 0) return;
const expanded = expandTreeForPath(treeRef.current, filePath);
dispatch({ type: ACTIONS.SET_DIRECTORY_TREE, payload: expanded });
};
const loadDirectoryTree = async () => {
try {
console.log('[MengyaNote] 开始加载目录树...');
dispatch({ type: ACTIONS.SET_LOADING, payload: true });
const tree = await readDirectoryTree();
const urlFilePath = urlToFilePath(window.location.pathname);
const [tree, preloadedFile] = await Promise.all([
readDirectoryTree(),
urlFilePath ? readMarkdownFile(urlFilePath) : Promise.resolve(null),
]);
console.log('[MengyaNote] 目录树加载成功,节点数:', tree.length);
const defaultPath = findDefaultFilePath(tree);
console.log('[MengyaNote] 默认文件路径:', defaultPath);
const enhancedTree = expandTreeForPath(tree, defaultPath);
const initialPath = urlFilePath || findDefaultFilePath(tree);
console.log('[MengyaNote] 初始文件路径:', initialPath, urlFilePath ? '(来自URL)' : '(默认)');
const enhancedTree = expandTreeForPath(tree, initialPath);
treeRef.current = enhancedTree;
dispatch({ type: ACTIONS.SET_DIRECTORY_TREE, payload: enhancedTree });
if (!state.currentFile && defaultPath) {
console.log('[MengyaNote] 加载默认文件...');
await selectFile(defaultPath);
if (urlFilePath) {
dispatch({ type: ACTIONS.SET_CURRENT_FILE, payload: urlFilePath });
dispatch({ type: ACTIONS.SET_CURRENT_CONTENT, payload: preloadedFile.content });
dispatch({ type: ACTIONS.SET_METADATA, payload: preloadedFile.metadata });
dispatch({ type: ACTIONS.SET_LOADING, payload: false });
} else if (initialPath && !state.currentFile) {
await selectFile(initialPath, { pushUrl: true });
} else {
dispatch({ type: ACTIONS.SET_LOADING, payload: false });
}
console.log('[MengyaNote] 初始化完成');
} catch (error) {
console.error('[MengyaNote] 加载目录树失败:', error);
@@ -214,10 +267,28 @@ export function AppProvider({ children }) {
dispatch({ type: ACTIONS.TOGGLE_NODE_EXPANSION, payload: nodePath });
};
// 监听浏览器前进/后退
useEffect(() => {
const handlePopState = (e) => {
const filePath = e.state?.filePath || urlToFilePath(window.location.pathname);
if (filePath) {
selectFile(filePath, { pushUrl: false });
expandForPath(filePath);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
useEffect(() => {
loadDirectoryTree();
}, []);
// 保持 treeRef 与 state.directoryTree 同步
useEffect(() => {
treeRef.current = state.directoryTree;
}, [state.directoryTree]);
const value = {
...state,
loadDirectoryTree,

View File

@@ -72,6 +72,119 @@ export async function readDirectoryTree() {
}
}
function adminApiUrl(path) {
if (USE_RELATIVE_PATH) {
return path.startsWith('/') ? path : `/${path}`;
}
const base = API_BASE.endsWith('/') ? API_BASE.slice(0, -1) : API_BASE;
return `${base}${path.startsWith('/') ? path : `/${path}`}`;
}
function parseApiError(res, body) {
const d = body?.detail;
if (typeof d === 'string') return d;
if (Array.isArray(d)) return d.map((x) => x.msg || String(x)).join(', ');
return `请求失败 ${res.status}`;
}
/** 管理员登录(仅校验 token密钥不落前端代码 */
export async function adminLogin(token) {
const url = adminApiUrl('/api/admin/login');
const res = await fetchWithTimeout(
url,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
},
15000
);
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(parseApiError(res, body));
}
return body;
}
/** 获取忽略文件夹列表 */
export async function fetchIgnoreList(token) {
const url = adminApiUrl('/api/admin/ignore');
const res = await fetchWithTimeout(
url,
{
headers: { 'X-Admin-Token': token },
},
15000
);
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(parseApiError(res, body));
}
return body;
}
/** 添加忽略文件夹 */
export async function addIgnoreFolder(token, name) {
const url = adminApiUrl('/api/admin/ignore');
const res = await fetchWithTimeout(
url,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Admin-Token': token,
},
body: JSON.stringify({ name }),
},
15000
);
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(parseApiError(res, body));
}
return body;
}
/** 重命名忽略项 */
export async function updateIgnoreFolder(token, oldName, newName) {
const url = adminApiUrl('/api/admin/ignore');
const res = await fetchWithTimeout(
url,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Admin-Token': token,
},
body: JSON.stringify({ old: oldName, new: newName }),
},
15000
);
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(parseApiError(res, body));
}
return body;
}
/** 删除忽略项 */
export async function deleteIgnoreFolder(token, name) {
const url = `${adminApiUrl('/api/admin/ignore')}?${new URLSearchParams({ name })}`;
const res = await fetchWithTimeout(
url,
{
method: 'DELETE',
headers: { 'X-Admin-Token': token },
},
15000
);
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(parseApiError(res, body));
}
return body;
}
// 从 FastAPI 后端读取Markdown文件内容
export async function readMarkdownFile(relativePath) {
console.log('[MengyaNote] 请求文件:', relativePath);

View File

@@ -63,14 +63,14 @@ export default defineConfig({
devOptions: { enabled: true } // 开发时也启用 PWA 便于调试
})
],
base: './', // Ensure relative paths for assets
base: '/', // 使用绝对路径,确保 SPA 路由(/note/...)下资源加载正确
server: {
host: '0.0.0.0', // 监听所有网络接口(包括局域网)
port: 5173, // 端口号可选默认5173
proxy: {
// 开发环境代理配置
// 开发:将 /api 转到本机 FastAPI默认 uvicorn 端口 8000与 mengyanote-backend 一致)
'/api': {
target: 'http://192.168.1.233:2424',
target: 'http://127.0.0.1:8000',
changeOrigin: true,
}
}
@@ -79,9 +79,9 @@ export default defineConfig({
host: '0.0.0.0',
port: 4173,
proxy: {
// 预览环境代理配置(npm run preview
// npm run preview:同上,本地联调后端
'/api': {
target: 'http://192.168.1.233:2424',
target: 'http://127.0.0.1:8000',
changeOrigin: true,
}
}