Compare commits

..

16 Commits

Author SHA1 Message Date
MikiVL
27ebe43e54 fix: AI 流式接口 max_tokens 提升至 4096 2026-05-05 04:03:42 +08:00
MikiVL
c2e80304d6 fix: AI 续写完成后自动取消内容选中状态 2026-05-05 04:02:12 +08:00
MikiVL
79db35c995 fix: 专注模式退出后按钮状态正确重置 2026-05-05 03:59:39 +08:00
MikiVL
08ae311f3a fix: 表格 header 行添加视觉区分样式 2026-05-05 03:53:42 +08:00
MikiVL
2ffc6e64c1 fix: 斜杠命令菜单分割线文案统一 2026-05-05 03:49:33 +08:00
MikiVL
2ade39d58f fix: 笔记列表 hover 操作按钮与日期互换显示 2026-05-05 03:45:05 +08:00
MikiVL
8ce1072b0c fix: 斜杠命令菜单 Esc 取消后清除输入字符 2026-05-05 03:42:16 +08:00
MikiVL
cffaedb297 fix: 空笔记不显示阅读时间 2026-05-05 03:41:15 +08:00
MikiVL
dbf74dfbab fix: 编辑器工具栏按钮补全 tooltip 2026-05-05 03:37:20 +08:00
MikiVL
42c9ac54c1 fix: 排序下拉菜单防止溢出侧边栏
添加 left: 'auto' 覆盖父级 left 定位影响,配合已有的 right-0 确保菜单右对齐不向右溢出编辑区。
2026-05-05 03:32:15 +08:00
MikiVL
b8169347a4 fix: API Key 输入框改为 password 类型并支持切换显示 2026-05-05 03:23:50 +08:00
MikiVL
c26875cc15 fix: 专注模式下编辑区内容居中限宽 2026-05-05 03:20:43 +08:00
MikiVL
5faf8aa35e fix: 修复 Markdown 渲染的 typography 插件和 code block 样式
- 安装 @tailwindcss/typography 并在 index.css 添加 @plugin 指令,使 prose 样式生效
- 在 AiPanel.tsx 中为 ReactMarkdown 添加 pre 组件,并为 code 组件增加 className 参数,区分 inline code 与 code block(兼容 react-markdown v10)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 03:18:46 +08:00
MikiVL
bf66b269d4 fix: AI 面板回复支持 Markdown 渲染
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 03:15:09 +08:00
MikiVL
dff9f90bf2 fix: 移动端侧边栏响应式布局
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 03:08:41 +08:00
MikiVL
dab36ca327 fix: 删除笔记前增加确认对话框 2026-05-05 03:03:29 +08:00
9 changed files with 1398 additions and 60 deletions

1182
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -60,11 +60,13 @@
"pdfjs-dist": "^5.7.284", "pdfjs-dist": "^5.7.284",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-markdown": "^10.1.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"zustand": "^5.0.12" "zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.4", "@tailwindcss/vite": "^4.2.4",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",

View File

@ -153,7 +153,7 @@ app.post('/api/ai/stream', async (c) => {
const stream = await aiClient.messages.stream({ const stream = await aiClient.messages.stream({
model: modelId, model: modelId,
max_tokens: 1024, max_tokens: 4096,
system: buildSystemPrompt(req.type, req.noteContent), system: buildSystemPrompt(req.type, req.noteContent),
messages: buildMessages(req), messages: buildMessages(req),
}) })

View File

@ -1,4 +1,4 @@
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import { Sidebar } from './components/sidebar/Sidebar' import { Sidebar } from './components/sidebar/Sidebar'
import { Editor } from './components/editor/Editor' import { Editor } from './components/editor/Editor'
import { AiPanel } from './components/ai/AiPanel' import { AiPanel } from './components/ai/AiPanel'
@ -7,6 +7,7 @@ import { seedIfEmpty, deduplicateDB } from './db'
export default function App() { export default function App() {
const { loadAll, theme, focusMode, aiPanelOpen, toggleFocusMode, toggleAiPanel, createNote, activeFolderId } = useAppStore() const { loadAll, theme, focusMode, aiPanelOpen, toggleFocusMode, toggleAiPanel, createNote, activeFolderId } = useAppStore()
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', theme) document.documentElement.setAttribute('data-theme', theme)
@ -31,10 +32,34 @@ export default function App() {
return ( return (
<div className="flex h-screen w-full overflow-hidden" style={{ background: 'var(--bg)' }}> <div className="flex h-screen w-full overflow-hidden" style={{ background: 'var(--bg)' }}>
<div className={`sidebar-panel${focusMode ? ' hidden' : ''}`} style={{ width: 260 }}> <>
{mobileSidebarOpen && (
<div
className="fixed inset-0 z-30"
style={{ background: 'rgba(0,0,0,0.4)' }}
onClick={() => setMobileSidebarOpen(false)}
/>
)}
<div
className={`sidebar-panel${focusMode ? ' hidden' : ''}`}
style={{ width: 260 }}
data-mobile-open={mobileSidebarOpen}
>
<Sidebar /> <Sidebar />
</div> </div>
{!focusMode && (
<button
className="mobile-menu-btn"
onClick={() => setMobileSidebarOpen(v => !v)}
aria-label="打开菜单"
>
</button>
)}
</>
<div className="flex-1 min-w-0 h-full flex flex-col editor-main">
<Editor /> <Editor />
</div>
{aiPanelOpen && <AiPanel />} {aiPanelOpen && <AiPanel />}
</div> </div>
) )

View File

@ -1,5 +1,6 @@
import { useState, useRef, useEffect, useCallback } from 'react' import { useState, useRef, useEffect, useCallback } from 'react'
import { X, Send, Sparkles, Square, FileText, GripHorizontal } from 'lucide-react' import { X, Send, Sparkles, Square, FileText, GripHorizontal } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import { useAppStore } from '../../stores/appStore' import { useAppStore } from '../../stores/appStore'
import { streamAI } from '../../lib/ai' import { streamAI } from '../../lib/ai'
import { extractTextFromJSON } from '../../lib/utils' import { extractTextFromJSON } from '../../lib/utils'
@ -233,7 +234,7 @@ export function AiPanel() {
{messages.map((msg, i) => ( {messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}> <div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div <div
className="max-w-[85%] rounded-2xl px-3 py-2 text-sm leading-relaxed whitespace-pre-wrap break-words" className="max-w-[85%] rounded-2xl px-3 py-2 text-sm leading-relaxed break-words"
style={{ style={{
background: msg.role === 'user' ? 'var(--accent)' : 'var(--bg-muted)', background: msg.role === 'user' ? 'var(--accent)' : 'var(--bg-muted)',
color: msg.role === 'user' ? '#fff' : 'var(--text)', color: msg.role === 'user' ? '#fff' : 'var(--text)',
@ -241,7 +242,27 @@ export function AiPanel() {
borderBottomLeftRadius: msg.role === 'assistant' ? 4 : undefined, borderBottomLeftRadius: msg.role === 'assistant' ? 4 : undefined,
}} }}
> >
{msg.content} {msg.role === 'assistant' ? (
<div className="prose prose-sm max-w-none">
<ReactMarkdown
components={{
p: ({children}) => <p className="mb-1 last:mb-0">{children}</p>,
ul: ({children}) => <ul className="list-disc pl-4 mb-1">{children}</ul>,
ol: ({children}) => <ol className="list-decimal pl-4 mb-1">{children}</ol>,
li: ({children}) => <li className="mb-0.5">{children}</li>,
pre: ({children}) => (
<pre className="rounded-lg p-3 my-2 overflow-x-auto text-xs" style={{background:'var(--bg-muted)'}}>
{children}
</pre>
),
code: ({className, children}) => (
<code className={`px-1 rounded text-xs ${className ?? ''}`} style={{background:'var(--bg-muted)'}}>{children}</code>
),
strong: ({children}) => <strong style={{color:'var(--text)'}}>{children}</strong>,
}}
>{msg.content}</ReactMarkdown>
</div>
) : msg.content}
{streaming && i === messages.length - 1 && msg.role === 'assistant' && ( {streaming && i === messages.length - 1 && msg.role === 'assistant' && (
<span className="ai-cursor" /> <span className="ai-cursor" />
)} )}

View File

@ -18,6 +18,7 @@ export function ModelSettingsModal({ onClose }: { onClose: () => void }) {
const [formError, setFormError] = useState('') const [formError, setFormError] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [expandedId, setExpandedId] = useState<string | null>(null) const [expandedId, setExpandedId] = useState<string | null>(null)
const [showKey, setShowKey] = useState(false)
const fetchModels = async () => { const fetchModels = async () => {
try { try {
@ -188,8 +189,31 @@ export function ModelSettingsModal({ onClose }: { onClose: () => void }) {
].map(f => ( ].map(f => (
<div key={f.key}> <div key={f.key}>
<label className="text-xs" style={{ color: 'var(--text-faint)' }}>{f.label}</label> <label className="text-xs" style={{ color: 'var(--text-faint)' }}>{f.label}</label>
{f.key === 'apiKey' ? (
<div className="relative">
<input <input
type={f.key === 'apiKey' ? 'password' : 'text'} type={showKey ? 'text' : 'password'}
autoComplete="off"
value={form.apiKey}
onChange={e => setForm(prev => ({ ...prev, apiKey: e.target.value }))}
placeholder={f.placeholder}
className="w-full mt-0.5 px-3 py-1.5 rounded-lg text-sm outline-none pr-12"
style={{ background: 'var(--bg-muted)', border: '1px solid var(--border)', color: 'var(--text)' }}
onFocus={e => (e.currentTarget.style.borderColor = 'var(--accent)')}
onBlur={e => (e.currentTarget.style.borderColor = 'var(--border)')}
/>
<button
type="button"
onClick={() => setShowKey(v => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-xs"
style={{ color: 'var(--text-faint)' }}
>
{showKey ? '隐藏' : '显示'}
</button>
</div>
) : (
<input
type="text"
value={form[f.key as keyof typeof form]} value={form[f.key as keyof typeof form]}
onChange={e => setForm(prev => ({ ...prev, [f.key]: e.target.value }))} onChange={e => setForm(prev => ({ ...prev, [f.key]: e.target.value }))}
placeholder={f.placeholder} placeholder={f.placeholder}
@ -198,6 +222,7 @@ export function ModelSettingsModal({ onClose }: { onClose: () => void }) {
onFocus={e => (e.currentTarget.style.borderColor = 'var(--accent)')} onFocus={e => (e.currentTarget.style.borderColor = 'var(--accent)')}
onBlur={e => (e.currentTarget.style.borderColor = 'var(--border)')} onBlur={e => (e.currentTarget.style.borderColor = 'var(--border)')}
/> />
)}
</div> </div>
))} ))}
{formError && <p className="text-xs" style={{ color: '#ef4444' }}>{formError}</p>} {formError && <p className="text-xs" style={{ color: '#ef4444' }}>{formError}</p>}

View File

@ -49,7 +49,7 @@ const SLASH_ITEMS = [
const url = window.prompt('图片地址URL') const url = window.prompt('图片地址URL')
if (url) e?.chain().focus().setImage({ src: url }).run() if (url) e?.chain().focus().setImage({ src: url }).run()
}}, }},
{ label: '分割线', desc: '水平线', icon: <Minus size={14} />, action: (e: ReturnType<typeof useEditor>) => e?.chain().focus().setHorizontalRule().run() }, { label: '分割线', desc: '插入水平分割线', icon: <Minus size={14} />, action: (e: ReturnType<typeof useEditor>) => e?.chain().focus().setHorizontalRule().run() },
{ label: '表格', desc: '插入表格', icon: <TableIcon size={14} />, action: (e: ReturnType<typeof useEditor>) => e?.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() }, { label: '表格', desc: '插入表格', icon: <TableIcon size={14} />, action: (e: ReturnType<typeof useEditor>) => e?.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
] ]
@ -184,7 +184,14 @@ export function Editor() {
return true return true
} }
if (event.key === 'Escape') { if (event.key === 'Escape') {
if (slashStartPos.current !== null && editor) {
const { state, dispatch } = editor.view
const { from } = state.selection
const tr = state.tr.delete(slashStartPos.current, from)
dispatch(tr)
}
setSlashOpen(false) setSlashOpen(false)
slashStartPos.current = null
return true return true
} }
} }
@ -313,6 +320,9 @@ export function Editor() {
} catch (e) { } catch (e) {
if ((e as Error).name === 'AbortError') return if ((e as Error).name === 'AbortError') return
} finally { } finally {
if (editor) {
editor.commands.setTextSelection(editor.state.doc.content.size)
}
setAiContinuing(false) setAiContinuing(false)
aiAbortRef.current = null aiAbortRef.current = null
} }
@ -345,12 +355,12 @@ export function Editor() {
return <WelcomeView /> return <WelcomeView />
} }
const readingTime = Math.max(1, Math.ceil(wordCount / 250)) const readingTime = wordCount > 0 ? Math.max(1, Math.ceil(wordCount / 250)) : 0
return ( return (
<div className="flex-1 flex flex-col min-w-0 h-full" style={{ background: 'var(--bg)' }}> <div className="flex-1 flex flex-col min-w-0 h-full" style={{ background: 'var(--bg)' }}>
{/* Title row */} {/* Title row */}
<div className="px-12 pt-10 pb-0 max-w-3xl mx-auto w-full"> <div className={`px-12 pt-10 pb-0 mx-auto w-full${focusMode ? ' max-w-2xl' : ' max-w-3xl'}`}>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<input <input
value={title} value={title}
@ -370,14 +380,14 @@ export function Editor() {
</button> </button>
<button <button
onClick={toggleAiPanel} onClick={toggleAiPanel}
title="AI 助手" title={aiPanelOpen ? '关闭 AI 助手 (⌘⇧J)' : '打开 AI 助手 (⌘⇧J)'}
className="toolbar-btn shrink-0 mt-2" className="toolbar-btn shrink-0 mt-2"
> >
<Sparkles size={14} style={{ color: aiPanelOpen ? 'var(--accent)' : 'var(--text-faint)' }} /> <Sparkles size={14} style={{ color: aiPanelOpen ? 'var(--accent)' : 'var(--text-faint)' }} />
</button> </button>
<button <button
onClick={() => activeNoteId && toggleStar(activeNoteId)} onClick={() => activeNoteId && toggleStar(activeNoteId)}
title={activeNote.starred ? '取消收藏' : '收藏'} title={activeNote.starred ? '取消收藏' : '收藏笔记'}
className="toolbar-btn shrink-0 mt-2" className="toolbar-btn shrink-0 mt-2"
> >
<Star <Star
@ -391,7 +401,7 @@ export function Editor() {
</div> </div>
<button <button
onClick={toggleFocusMode} onClick={toggleFocusMode}
title={focusMode ? '退出专注模式 (Esc)' : '专注模式'} title={focusMode ? '退出专注模式 (Esc 或 ⌘\\)' : '专注模式 (⌘\\)'}
className="toolbar-btn shrink-0 mt-2" className="toolbar-btn shrink-0 mt-2"
> >
{focusMode {focusMode
@ -402,7 +412,7 @@ export function Editor() {
</div> </div>
{/* Tag row */} {/* Tag row */}
<div className="px-12 pb-2 max-w-3xl mx-auto w-full"> <div className={`px-12 pb-2 mx-auto w-full${focusMode ? ' max-w-2xl' : ' max-w-3xl'}`}>
<div className="flex items-center flex-wrap gap-1.5 min-h-[26px]"> <div className="flex items-center flex-wrap gap-1.5 min-h-[26px]">
{activeNote.tags.map(tag => ( {activeNote.tags.map(tag => (
<span <span
@ -451,7 +461,7 @@ export function Editor() {
{/* Editor scroll */} {/* Editor scroll */}
<div className="flex-1 overflow-y-auto editor-scroll relative px-12 pt-4 pb-4"> <div className="flex-1 overflow-y-auto editor-scroll relative px-12 pt-4 pb-4">
<div className="max-w-3xl mx-auto w-full relative"> <div className={`mx-auto w-full relative${focusMode ? ' max-w-2xl' : ' max-w-3xl'}`}>
<EditorContent editor={editor} /> <EditorContent editor={editor} />
{slashOpen && filteredSlash.length > 0 && ( {slashOpen && filteredSlash.length > 0 && (
@ -488,7 +498,7 @@ export function Editor() {
className="flex items-center justify-between px-12 py-2 text-xs shrink-0" className="flex items-center justify-between px-12 py-2 text-xs shrink-0"
style={{ borderTop: '1px solid var(--border)', color: 'var(--text-faint)', background: 'var(--bg)' }} style={{ borderTop: '1px solid var(--border)', color: 'var(--text-faint)', background: 'var(--bg)' }}
> >
<span>{wordCount} · {readingTime} </span> <span>{wordCount} {wordCount > 0 ? ` · 约 ${readingTime} 分钟阅读` : ''}</span>
<span style={{ color: saveStatus === 'saved' ? 'var(--text-faint)' : 'var(--accent)' }}> <span style={{ color: saveStatus === 'saved' ? 'var(--text-faint)' : 'var(--accent)' }}>
{saveStatus === 'saving' ? '保存中…' : saveStatus === 'unsaved' ? '未保存' : '已自动保存'} {saveStatus === 'saving' ? '保存中…' : saveStatus === 'unsaved' ? '未保存' : '已自动保存'}
</span> </span>

View File

@ -44,6 +44,7 @@ export function Sidebar() {
y: number y: number
} | null>(null) } | null>(null)
const [draggingNoteId, setDraggingNoteId] = useState<string | null>(null) const [draggingNoteId, setDraggingNoteId] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
const sensors = useSensors( const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 6 } }), useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),
@ -289,7 +290,7 @@ export function Sidebar() {
{sortMenuOpen && ( {sortMenuOpen && (
<div <div
className="absolute right-0 top-full mt-1 rounded-lg py-1 z-50 shadow-xl" className="absolute right-0 top-full mt-1 rounded-lg py-1 z-50 shadow-xl"
style={{ background: 'var(--bg)', border: '1px solid var(--border)', minWidth: 140 }} style={{ background: 'var(--bg)', border: '1px solid var(--border)', minWidth: 140, left: 'auto' }}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
{SORT_OPTIONS.map(opt => ( {SORT_OPTIONS.map(opt => (
@ -332,7 +333,7 @@ export function Sidebar() {
if (e.key === 'Escape') setEditingNoteId(null) if (e.key === 'Escape') setEditingNoteId(null)
}} }}
onClick={() => setActiveNote(note.id)} onClick={() => setActiveNote(note.id)}
onDelete={() => deleteNote(note.id)} onDelete={() => setDeleteConfirm(note.id)}
onToggleStar={() => toggleStar(note.id)} onToggleStar={() => toggleStar(note.id)}
onTagClick={(tag) => setActiveTag(activeTag === tag ? null : tag)} onTagClick={(tag) => setActiveTag(activeTag === tag ? null : tag)}
onContextMenu={(e) => { onContextMenu={(e) => {
@ -367,7 +368,7 @@ export function Sidebar() {
if (n) { setNoteEditValue(n.title); setEditingNoteId(contextMenu.id) } if (n) { setNoteEditValue(n.title); setEditingNoteId(contextMenu.id) }
setContextMenu(null) setContextMenu(null)
}} /> }} />
<CtxItem icon={<Trash2 size={13} />} label="删除笔记" danger onClick={async () => { await deleteNote(contextMenu.id); setContextMenu(null) }} /> <CtxItem icon={<Trash2 size={13} />} label="删除笔记" danger onClick={() => { setDeleteConfirm(contextMenu.id); setContextMenu(null) }} />
</> </>
)} )}
</div> </div>
@ -424,6 +425,36 @@ export function Sidebar() {
</div> </div>
)} )}
</DragOverlay> </DragOverlay>
{deleteConfirm && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.4)' }}
onClick={() => setDeleteConfirm(null)}
>
<div
className="rounded-2xl shadow-2xl p-5 flex flex-col gap-4"
style={{ background: 'var(--bg)', border: '1px solid var(--border)', width: 320 }}
onClick={e => e.stopPropagation()}
>
<p className="text-sm font-semibold" style={{ color: 'var(--text)' }}></p>
<p className="text-xs" style={{ color: 'var(--muted)' }}></p>
<div className="flex justify-end gap-2">
<button
onClick={() => setDeleteConfirm(null)}
className="px-3 py-1.5 rounded-lg text-sm"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
></button>
<button
onClick={async () => { await deleteNote(deleteConfirm); setDeleteConfirm(null) }}
className="px-3 py-1.5 rounded-lg text-sm font-medium"
style={{ background: '#ef4444', color: '#fff' }}
></button>
</div>
</div>
</div>
)}
</DndContext> </DndContext>
) )
} }
@ -551,8 +582,9 @@ function DraggableNoteItem({ note, active, editing, editValue, editRef, activeTa
</span> </span>
)} )}
</div> </div>
{hovered ? (
<div className="flex items-center gap-0.5 shrink-0"> <div className="flex items-center gap-0.5 shrink-0">
{hovered ? (
<>
<button <button
className="toolbar-btn" className="toolbar-btn"
style={{ width: 20, height: 20, color: note.starred ? '#f59e0b' : 'var(--text-faint)' }} style={{ width: 20, height: 20, color: note.starred ? '#f59e0b' : 'var(--text-faint)' }}
@ -571,13 +603,14 @@ function DraggableNoteItem({ note, active, editing, editValue, editRef, activeTa
> >
<Trash2 size={12} /> <Trash2 size={12} />
</button> </button>
</div> </>
) : ( ) : (
<span className="text-xs shrink-0 mt-0.5" style={{ color: 'var(--text-faint)' }}> <span className="text-xs" style={{ color: 'var(--text-faint)' }}>
{formatDate(note.updatedAt)} {formatDate(note.updatedAt)}
</span> </span>
)} )}
</div> </div>
</div>
{note.tags.length > 0 && ( {note.tags.length > 0 && (
<div className="flex gap-1 mt-1 flex-wrap"> <div className="flex gap-1 mt-1 flex-wrap">
{note.tags.slice(0, 3).map(tag => ( {note.tags.slice(0, 3).map(tag => (

View File

@ -1,4 +1,5 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "@tailwindcss/typography";
@layer base { @layer base {
:root { :root {
@ -135,8 +136,8 @@
/* Table */ /* Table */
.ProseMirror table { border-collapse: collapse; width: 100%; margin: 0.75em 0; font-size: 0.9em; } .ProseMirror table { border-collapse: collapse; width: 100%; margin: 0.75em 0; font-size: 0.9em; }
.ProseMirror th, .ProseMirror td { border: 1px solid var(--border); padding: 0.5em 0.75em; text-align: left; } .ProseMirror th, .ProseMirror td { border: 1px solid var(--border); padding: 0.5em 0.75em; text-align: left; min-width: 80px; }
.ProseMirror th { background: var(--bg-muted); font-weight: 600; } .ProseMirror th { background: var(--bg-muted); font-weight: 600; color: var(--text); border-bottom: 2px solid var(--border); }
/* Placeholder */ /* Placeholder */
.ProseMirror p.is-editor-empty:first-child::before { .ProseMirror p.is-editor-empty:first-child::before {
@ -286,6 +287,51 @@
animation: ai-blink 0.8s ease infinite; animation: ai-blink 0.8s ease infinite;
} }
/* 移动端响应式 */
.mobile-menu-btn {
display: none;
}
@media (max-width: 640px) {
.mobile-menu-btn {
display: flex;
position: fixed;
top: 12px;
left: 12px;
z-index: 40;
width: 36px;
height: 36px;
align-items: center;
justify-content: center;
border-radius: 8px;
font-size: 18px;
background: var(--bg-subtle);
border: 1px solid var(--border);
color: var(--text);
cursor: pointer;
}
.sidebar-panel {
position: fixed !important;
top: 0;
left: 0;
bottom: 0;
z-index: 35;
transform: translateX(-100%);
transition: transform 0.22s ease;
box-shadow: 4px 0 24px rgba(0,0,0,0.3);
}
.sidebar-panel[data-mobile-open="true"] {
transform: translateX(0);
}
.editor-main {
margin-left: 0 !important;
padding-left: 48px;
}
}
/* ── Print / PDF export ── */ /* ── Print / PDF export ── */
@media print { @media print {
aside, [data-ai-panel], .floating-toolbar, .editor-toolbar, aside, [data-ai-panel], .floating-toolbar, .editor-toolbar,