chore: sync local updates
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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`。
|
||||
@@ -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.
BIN
mengyanote-frontend/public/favicon.ico
Normal file
BIN
mengyanote-frontend/public/favicon.ico
Normal file
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 |
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
195
mengyanote-frontend/src/components/AdminPanel.css
Normal file
195
mengyanote-frontend/src/components/AdminPanel.css
Normal 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;
|
||||
}
|
||||
207
mengyanote-frontend/src/components/AdminPanel.jsx
Normal file
207
mengyanote-frontend/src/components/AdminPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
mengyanote-frontend/src/components/AdminTokenModal.css
Normal file
89
mengyanote-frontend/src/components/AdminTokenModal.css
Normal 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);
|
||||
}
|
||||
76
mengyanote-frontend/src/components/AdminTokenModal.jsx
Normal file
76
mengyanote-frontend/src/components/AdminTokenModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
/* 仅 transform,GPU 合成,不触发 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; /* ???????? */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
48
mengyanote-frontend/src/components/SiteHeader.css
Normal file
48
mengyanote-frontend/src/components/SiteHeader.css
Normal 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;
|
||||
}
|
||||
23
mengyanote-frontend/src/components/SiteHeader.jsx
Normal file
23
mengyanote-frontend/src/components/SiteHeader.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
mengyanote-frontend/src/context/AdminContext.jsx
Normal file
116
mengyanote-frontend/src/context/AdminContext.jsx
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user