Add storage

This commit is contained in:
2026-05-15 19:01:10 +08:00
parent 8a16b01e9e
commit 8aa7387e4d
49 changed files with 2112 additions and 223 deletions
@@ -79,20 +79,19 @@ const skipCollaborationInit =
window.parent != null && window.parent.frames.right === window;
function OnChangePlugin({ onChange }) {
function OnChangePlugin({ onChange }: { onChange: (editorState: unknown) => void }): null {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerUpdateListener(({editorState}) => {
onChange(editorState);
});
}, [editor, onChange]);
return null;
}
export default function Editor(props: { noteId: bigint; noteTitle: string; editData: any; handleLawClick_item2: any; }): JSX.Element {
export default function Editor(props: { noteId: number; noteTitle: string; editData: string | null; handleLawClick_item2: any; }): JSX.Element {
const [editor] = useLexicalComposerContext();
console.log(props.noteId)
const {historyState} = useSharedHistoryContext();
const {
settings: {
@@ -121,7 +120,7 @@ export default function Editor(props: { noteId: bigint; noteTitle: string; editD
const [isSmallWidthViewport, setIsSmallWidthViewport] =
useState<boolean>(false);
const [isLinkEditMode, setIsLinkEditMode] = useState<boolean>(false);
const [editorState, setEditorState] = useState();
const [editorState, setEditorState] = useState<string | undefined>(undefined);
const onRef = (_floatingAnchorElem: HTMLDivElement) => {
if (_floatingAnchorElem !== null) {
setFloatingAnchorElem(_floatingAnchorElem);
@@ -146,18 +145,11 @@ export default function Editor(props: { noteId: bigint; noteTitle: string; editD
}, [isSmallWidthViewport]);
function onChange(editorState) {
// Call toJSON on the EditorState object, which produces a serialization safe string
function onChange(editorState: { toJSON: () => unknown }) {
const editorStateJSON = editorState.toJSON();
// However, we still have a JavaScript object, so we need to convert it to an actual string with JSON.stringify
setEditorState(JSON.stringify(editorStateJSON));
}
console.log(props.noteId)
// console.log(props.editData)
return (
<>
{isRichText && <ToolbarPlugin setIsLinkEditMode={setIsLinkEditMode} />}
@@ -270,7 +262,7 @@ export default function Editor(props: { noteId: bigint; noteTitle: string; editD
{isAutocomplete && <AutocompletePlugin />}
<div>{showTableOfContents && <TableOfContentsPlugin />}</div>
{shouldUseLexicalContextMenu && <ContextMenuPlugin />}
<ActionsPlugin isRichText={isRichText} noteId={props.noteId} noteTitle={props.noteTitle} editData ={props.editData} handleLawClick_item3={props.handleLawClick_item2}/>
<ActionsPlugin noteId={props.noteId} noteTitle={props.noteTitle} editData={props.editData} handleLawClick_item3={props.handleLawClick_item2}/>
</div>
{showTreeView && <TreeViewPlugin />}
</>
@@ -112,16 +112,11 @@ function prepopulatedRichText() {
}
}
function LexicalApp(props: { noteId: bigint; editData: object | null; noteTitle: string; handleLawClick_item1: any; }): JSX.Element {
function LexicalApp(props: { noteId: number; editData: string | null; noteTitle: string; handleLawClick_item1: any; }): JSX.Element {
const {
settings: {isCollab, emptyEditor, measureTypingPerf},
} = useSettings();
console.log(props.noteId)
// console.log(props.editData)
console.log(props.editData!==null)
console.log(prepopulatedRichText)
const resolveEditorState = (): string | null | undefined => {
if (props.editData === null) return undefined; // use prepopulatedRichText via function below
const raw = props.editData as unknown as string;
@@ -179,7 +174,7 @@ function LexicalApp(props: { noteId: bigint; editData: object | null; noteTitle:
);
}
export default function PlaygroundApp(props: { noteId: bigint; noteTitle: string; editData: object; handleLawClick: any; }): JSX.Element {
export default function PlaygroundApp(props: { noteId: number; noteTitle: string; editData: string | null; handleLawClick: any; }): JSX.Element {
return (
// <Editor />
@@ -39,6 +39,32 @@ import NoteService from "../../../../services/note.service.ts";
import {Toast} from "@douyinfe/semi-ui";
import {SAVE_COMMAND} from "../GlobalEventsPlugin";
function exportMarkdown(editor: LexicalEditor, title: string) {
let markdown = '';
editor.getEditorState().read(() => {
markdown = $convertToMarkdownString(PLAYGROUND_TRANSFORMERS);
});
const blob = new Blob([`# ${title}\n\n${markdown}`], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title || 'note'}.md`;
a.click();
URL.revokeObjectURL(url);
}
function exportHtml(title: string) {
const content = document.querySelector('.editor-scroller')?.innerHTML ?? '';
const html = `<!DOCTYPE html><html lang="zh"><head><meta charset="UTF-8"><title>${title}</title></head><body><h1>${title}</h1>${content}</body></html>`;
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title || 'note'}.html`;
a.click();
URL.revokeObjectURL(url);
}
async function sendEditorState(editor: LexicalEditor): Promise<void> {
const stringifiedEditorState = JSON.stringify(editor.getEditorState());
try {
@@ -77,7 +103,7 @@ async function validateEditorState(editor: LexicalEditor): Promise<void> {
}
}
export default function ActionsPlugin(props: { noteId: bigint; noteTitle: string;handleLawClick_item3:any; }): JSX.Element {
export default function ActionsPlugin(props: { noteId: number; noteTitle: string; handleLawClick_item3: any; editData?: any }): JSX.Element {
const [editor] = useLexicalComposerContext();
const [isEditable, setIsEditable] = useState(() => editor.isEditable());
const [isSpeechToText, setIsSpeechToText] = useState(false);
@@ -169,29 +195,22 @@ export default function ActionsPlugin(props: { noteId: bigint; noteTitle: string
});
}, [editor]);
const saveData = (editor) => {
const x = document.getElementsByClassName("semi-input semi-input-large");
console.log('================当前noteTitle x: ', x)
console.log('当前ID: ', props.noteId)
console.log('当前noteTitle: ', props.noteTitle)
console.log('当前noteTitle2: ', x[0].value)
// console.log('当前editor3: ', editor.getEditorState().toJSON())
const obj = editor.getEditorState().toJSON();
// console.log('当前editor4: ', JSON.stringify(obj))
NoteService.update(props.noteId,x[0].value, JSON.stringify(obj)).then(
response => {
console.log(response.data)
const data = response.data
console.log(data)
console.log('handleLawClick_item3')
props.handleLawClick_item3('1');
Toast.info('保存成功' );
},
error => {
console.log(error)
}
);
const saveData = (editor: LexicalEditor) => {
const currentTitle = (document.querySelector('.semi-input.semi-input-large') as HTMLInputElement)?.value ?? props.noteTitle;
const obj = editor.getEditorState().toJSON();
import('../../../../stores/useNoteStore').then(({ useNoteStore }) => {
const tags = useNoteStore.getState().currentTags;
NoteService.update(props.noteId, currentTitle, JSON.stringify(obj), tags).then(
() => {
props.handleLawClick_item3('1');
Toast.info('保存成功');
useNoteStore.getState().setDirty(false);
},
() => {
Toast.error('保存失败');
}
);
}).catch(() => {});
};
return (
@@ -235,10 +254,24 @@ export default function ActionsPlugin(props: { noteId: bigint; noteTitle: string
source: 'Playground',
})
}
title="Export"
title="Export JSON"
aria-label="Export editor state to JSON">
<i className="export" />
</button>
<button
className="action-button export"
onClick={() => exportMarkdown(editor, props.noteTitle)}
title="导出 Markdown"
aria-label="Export as Markdown">
<i className="export" />
</button>
<button
className="action-button export"
onClick={() => exportHtml(props.noteTitle)}
title="导出 HTML"
aria-label="Export as HTML">
<i className="export" />
</button>
<button
className="action-button clear"
disabled={isEditorEmpty}
@@ -0,0 +1,105 @@
import { useState, useEffect, useCallback } from 'react';
import { Modal, List, Button, Toast, Typography, Spin, Popconfirm } from '@douyinfe/semi-ui';
import { IconHistory, IconRotate } from '@douyinfe/semi-icons';
import { useNoteStore } from '../../stores/useNoteStore';
import noteService from '../../services/note.service';
import type { IHistory } from '../../types';
const { Text } = Typography;
interface Props {
visible: boolean;
onClose: () => void;
}
export default function NoteHistoryModal({ visible, onClose }: Props) {
const { currentNoteId, setCurrentNote } = useNoteStore();
const [list, setList] = useState<IHistory[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!visible || !currentNoteId) return;
setLoading(true);
noteService
.getHistory(currentNoteId)
.then((res) => setList(res.data ?? []))
.catch(() => Toast.error('加载历史版本失败'))
.finally(() => setLoading(false));
}, [visible, currentNoteId]);
const handleRestore = useCallback(
async (item: IHistory) => {
try {
const res = await noteService.getHistoryContent(item.id);
const h = res.data;
// Restore: update note with history content
await noteService.update(h.nid, h.title, h.context);
// Reload the current note into the editor
const noteRes = await noteService.get(h.nid);
const note = noteRes.data;
setCurrentNote(note.id, note.title, note.context);
Toast.success(`已恢复到 ${h.createtime} 的版本`);
onClose();
} catch {
Toast.error('恢复失败');
}
},
[setCurrentNote, onClose],
);
return (
<Modal
title={
<span>
<IconHistory style={{ marginRight: 8 }} />
</span>
}
visible={visible}
onCancel={onClose}
footer={null}
width={520}
>
{loading ? (
<div style={{ textAlign: 'center', padding: 32 }}>
<Spin size="large" />
</div>
) : list.length === 0 ? (
<div style={{ textAlign: 'center', padding: 32, color: 'var(--semi-color-text-2)' }}>
</div>
) : (
<List
dataSource={list}
renderItem={(item: IHistory) => (
<List.Item
main={
<div>
<Text strong style={{ display: 'block' }}>{item.title}</Text>
<Text type="tertiary" size="small">{item.createtime}</Text>
</div>
}
extra={
<Popconfirm
title="确认恢复"
content="恢复后当前内容将被该版本覆盖,确定吗?"
onConfirm={() => void handleRestore(item)}
okText="确定"
cancelText="取消"
>
<Button
size="small"
type="secondary"
icon={<IconRotate />}
>
</Button>
</Popconfirm>
}
/>
)}
/>
)}
</Modal>
);
}
@@ -1,12 +1,32 @@
import { useCallback } from 'react';
import { Input, Skeleton, Breadcrumb } from '@douyinfe/semi-ui';
import { useCallback, useState } from 'react';
import { Input, Skeleton, Breadcrumb, Button, Tag, TagInput } from '@douyinfe/semi-ui';
import { IconHistory } from '@douyinfe/semi-icons';
import { useNoteStore } from '../../stores/useNoteStore';
import { useUIStore } from '../../stores/useUIStore';
import PlaygroundApp from '../lexical/LexicalApp';
import NoteHistoryModal from '../modals/NoteHistoryModal';
import type { ITreeNode } from '../../types';
function getNotePath(
nodes: ITreeNode[],
targetKey: string | number,
path: string[] = [],
): string[] | null {
for (const node of nodes) {
const current = [...path, node.label];
if (String(node.key) === String(targetKey)) return current;
if (node.children) {
const found = getNotePath(node.children, targetKey, current);
if (found) return found;
}
}
return null;
}
export default function NoteEditor() {
const { currentNoteId, currentTitle, currentContent, setTitle } = useNoteStore();
const { currentNoteId, currentTitle, currentContent, currentTags, setTitle, setTags, treeData } = useNoteStore();
const { isNoteLoading } = useUIStore();
const [historyVisible, setHistoryVisible] = useState(false);
const handleTitleChange = useCallback(
(value: string) => {
@@ -15,19 +35,37 @@ export default function NoteEditor() {
[setTitle],
);
// handleRefreshTree is intentionally a no-op here:
// LexicalApp calls it after structural changes, but tree refresh
// is driven by the NoteStore and handled at the NotePage level.
const handleTagsChange = useCallback(
(tags: string[]) => {
setTags(tags.join(','));
},
[setTags],
);
const handleRefreshTree = useCallback(() => {
// no-op: tree is refreshed by NotePage via store subscription
}, []);
const notePath = currentNoteId != null ? getNotePath(treeData, currentNoteId) : null;
const breadcrumbRoutes = ['CyyNote', ...(notePath ?? ['笔记标题', currentTitle])];
const tagValues = currentTags ? currentTags.split(',').filter(Boolean) : [];
return (
<>
<Breadcrumb
style={{ marginBottom: '16px', paddingTop: '10px' }}
routes={['CyyNote', '笔记标题', currentTitle]}
/>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '10px', marginBottom: '16px' }}>
<Breadcrumb routes={breadcrumbRoutes} />
{currentNoteId !== null && (
<Button
size="small"
theme="borderless"
icon={<IconHistory />}
onClick={() => setHistoryVisible(true)}
>
</Button>
)}
</div>
<NoteHistoryModal visible={historyVisible} onClose={() => setHistoryVisible(false)} />
<Skeleton
placeholder={<Skeleton.Paragraph rows={2} />}
loading={isNoteLoading}
@@ -37,8 +75,19 @@ export default function NoteEditor() {
size="large"
value={currentTitle}
onChange={handleTitleChange}
style={{ marginBottom: '12px' }}
style={{ marginBottom: '8px' }}
/>
{currentNoteId !== null && (
<TagInput
value={tagValues}
onChange={handleTagsChange}
placeholder="添加标签(回车确认)"
style={{ marginBottom: '12px' }}
renderTagItem={(value: string) => (
<Tag size="small" style={{ margin: '2px' }}>{value}</Tag>
)}
/>
)}
<div
style={{
borderRadius: '10px',
@@ -48,6 +97,7 @@ export default function NoteEditor() {
>
{currentNoteId !== null && currentContent !== null ? (
<PlaygroundApp
key={currentNoteId}
noteId={currentNoteId}
noteTitle={currentTitle}
editData={currentContent}
@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import type { CSSProperties, ReactNode } from 'react';
import { useCallback, useState } from 'react';
import type { CSSProperties, ReactNode, KeyboardEvent } from 'react';
import {
Tree,
List,
@@ -9,11 +9,13 @@ import {
Dropdown,
Typography,
Toast,
Modal,
} from '@douyinfe/semi-ui';
import {
IconCopyAdd,
IconDelete,
IconSearch,
IconSetting,
} from '@douyinfe/semi-icons';
import { IconModal, IconToken as LabIconToken, IconTreeSelect } from '@douyinfe/semi-icons-lab';
import { useNoteStore } from '../../stores/useNoteStore';
@@ -21,7 +23,7 @@ import { useUIStore } from '../../stores/useUIStore';
import noteService from '../../services/note.service';
import type { INote, ITreeNode } from '../../types';
const NAV_ITEMS = ['首页', '设置', '已删除'] as const;
const NAV_ITEMS = ['首页', '网盘', '设置', '已删除'] as const;
type NavItem = (typeof NAV_ITEMS)[number];
interface NoteTreeProps {
@@ -31,23 +33,40 @@ interface NoteTreeProps {
}
export default function NoteTree({ onRefreshTree, onNavItemClick, onSearch }: NoteTreeProps) {
const { treeData, viewData, currentNoteId, setCurrentNote } = useNoteStore();
const { treeData, viewData, currentNoteId, setCurrentNote, isDirty } = useNoteStore();
const { openDeleteConfirm, setSearchQuery, searchQuery, setActiveView } = useUIStore();
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editingValue, setEditingValue] = useState('');
const toNoteId = (id: string | number) => Number(id);
const loadNote = useCallback(
(id: number) => {
noteService.get(id).then(
(res) => {
const note = res.data;
setCurrentNote(note.id, note.title, note.context);
setActiveView('note');
},
() => Toast.error('加载笔记失败'),
);
const doLoad = () => {
noteService.get(id).then(
(res) => {
const note = res.data;
setCurrentNote(note.id, note.title, note.context, note.tags ?? '');
setActiveView('note');
},
() => Toast.error('加载笔记失败'),
);
};
if (isDirty) {
Modal.confirm({
title: '未保存的内容',
content: '当前笔记有未保存的内容,确定要离开吗?',
okText: '确定',
cancelText: '取消',
onOk: doLoad,
});
} else {
doLoad();
}
},
[setCurrentNote, setActiveView],
[setCurrentNote, setActiveView, isDirty],
);
const addNote = useCallback(
@@ -63,6 +82,19 @@ export default function NoteTree({ onRefreshTree, onNavItemClick, onSearch }: No
[onRefreshTree],
);
const renameNote = useCallback(
async (id: number, newTitle: string) => {
try {
const res = await noteService.get(id);
await noteService.update(id, newTitle, res.data.context);
onRefreshTree();
} catch {
Toast.error('重命名失败');
}
},
[onRefreshTree],
);
const treeStyle: CSSProperties = {
height: 550,
border: '1px solid var(--semi-color-border)',
@@ -70,35 +102,71 @@ export default function NoteTree({ onRefreshTree, onNavItemClick, onSearch }: No
top: '85px',
};
const renderLabel = (label: ReactNode, item: ITreeNode) => (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ width: 'calc(100% - 68px)' }}
>
{label}
</Typography.Text>
<ButtonGroup size="small" theme="borderless">
<Button
size="small"
icon={<IconCopyAdd />}
onClick={(e) => {
addNote(toNoteId(item.key));
const renderLabel = (label: ReactNode, item: ITreeNode) => {
const key = String(item.key);
const id = toNoteId(item.key);
if (editingKey === key) {
return (
<div style={{ display: 'flex', alignItems: 'center' }} onClick={(e) => e.stopPropagation()}>
<Input
autoFocus
size="small"
value={editingValue}
onChange={(v) => setEditingValue(v)}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
void renameNote(id, editingValue);
setEditingKey(null);
} else if (e.key === 'Escape') {
setEditingKey(null);
}
}}
onBlur={() => {
void renameNote(id, editingValue);
setEditingKey(null);
}}
style={{ width: 'calc(100% - 68px)' }}
/>
</div>
);
}
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ width: 'calc(100% - 68px)', cursor: 'text' }}
onDoubleClick={(e) => {
e.stopPropagation();
setEditingKey(key);
setEditingValue(String(label));
}}
/>
<Button
size="small"
type="warning"
icon={<IconDelete />}
onClick={(e) => {
openDeleteConfirm(toNoteId(item.key), String(label));
e.stopPropagation();
}}
/>
</ButtonGroup>
</div>
);
>
{label}
</Typography.Text>
<ButtonGroup size="small" theme="borderless">
<Button
size="small"
icon={<IconCopyAdd />}
onClick={(e) => {
addNote(id);
e.stopPropagation();
}}
/>
<Button
size="small"
type="warning"
icon={<IconDelete />}
onClick={(e) => {
openDeleteConfirm(id, String(label));
e.stopPropagation();
}}
/>
</ButtonGroup>
</div>
);
};
return (
<>
@@ -140,8 +208,9 @@ export default function NoteTree({ onRefreshTree, onNavItemClick, onSearch }: No
onClick={() => onNavItemClick(item)}
>
{item === '首页' && <Button type="danger" theme="borderless" icon={<LabIconToken />} style={{ marginRight: 4 }} />}
{item === '网盘' && <Button type="danger" theme="borderless" icon={<IconModal />} style={{ marginRight: 4 }} />}
{item === '设置' && <Button type="danger" theme="borderless" icon={<IconTreeSelect />} style={{ marginRight: 4 }} />}
{item === '已删除' && <Button type="danger" theme="borderless" icon={<IconModal />} style={{ marginRight: 4 }} />}
{item === '已删除' && <Button type="danger" theme="borderless" icon={<IconSetting />} style={{ marginRight: 4 }} />}
{item}
</div>
)}
@@ -189,12 +258,21 @@ export default function NoteTree({ onRefreshTree, onNavItemClick, onSearch }: No
searchPlaceholder=" "
showFilteredOnly
directory
draggable
virtualize={{ itemSize: 28 }}
value={currentNoteId === null ? undefined : String(currentNoteId)}
treeData={treeData as unknown as Parameters<typeof Tree>[0]['treeData']}
renderLabel={renderLabel as Parameters<typeof Tree>[0]['renderLabel']}
treeData={treeData as unknown as any}
renderLabel={renderLabel as any}
style={treeStyle}
onSelect={(id) => loadNote(toNoteId(id as string | number))}
onDrop={({ node, dragNode }: { node: any; dragNode: any }) => {
const id = toNoteId(dragNode.key);
const newPid = toNoteId(node.key);
noteService.move(id, newPid).then(
() => { Toast.success('移动成功'); onRefreshTree(); },
() => Toast.error('移动失败'),
);
}}
/>
</>
);
@@ -0,0 +1,313 @@
import { useState, useEffect, useCallback } from 'react';
import {
Table,
Button,
Toast,
Typography,
Upload,
Modal,
Input,
Tag,
Popconfirm,
Spin,
Empty,
Tabs,
TabPane,
Progress,
} from '@douyinfe/semi-ui';
import { IconUpload, IconLink, IconDelete, IconRefresh, IconDownload } from '@douyinfe/semi-icons';
import type { ColumnProps } from '@douyinfe/semi-ui/lib/es/table';
import driveService from '../../services/drive.service';
import settingsService from '../../services/settings.service';
import type { IDriveFile } from '../../types';
const { Title, Text } = Typography;
function formatSize(bytes: number | null): string {
if (!bytes) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
export default function DrivePage() {
const [files, setFiles] = useState<IDriveFile[]>([]);
const [loading, setLoading] = useState(false);
const [r2PublicUrl, setR2PublicUrl] = useState('');
const [activeTab, setActiveTab] = useState<'local' | 'r2'>('local');
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
// Share modal state
const [shareModalVisible, setShareModalVisible] = useState(false);
const [shareTarget, setShareTarget] = useState<IDriveFile | null>(null);
const [shareDays, setShareDays] = useState('7');
const [generatedLink, setGeneratedLink] = useState('');
const loadFiles = useCallback(() => {
setLoading(true);
driveService
.list()
.then((res) => setFiles(res.data ?? []))
.catch(() => Toast.error('加载文件列表失败'))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
loadFiles();
settingsService.getAll().then((res) => {
if (res.data?.r2_public_url) setR2PublicUrl(res.data.r2_public_url as string);
}).catch(() => {});
}, [loadFiles]);
const handleDelete = useCallback(async (id: number) => {
try {
await driveService.delete(id);
Toast.success('已删除');
loadFiles();
} catch {
Toast.error('删除失败');
}
}, [loadFiles]);
const handleShare = useCallback(async () => {
if (!shareTarget) return;
try {
const days = parseInt(shareDays, 10);
const res = await driveService.share(shareTarget.id, isNaN(days) ? undefined : days);
const token = res.data.share_token;
const baseUrl = window.location.origin;
if (shareTarget.storageMode === 'local') {
setGeneratedLink(`${baseUrl}/api/drive/public-download/${token}`);
} else {
setGeneratedLink(`${baseUrl}/api/drive/public/${token}`);
}
Toast.success('分享链接已生成');
} catch {
Toast.error('生成分享链接失败');
}
}, [shareTarget, shareDays]);
const handleUnshare = useCallback(async (id: number) => {
try {
await driveService.unshare(id);
Toast.success('分享链接已撤销');
loadFiles();
} catch {
Toast.error('操作失败');
}
}, [loadFiles]);
const handleLocalUpload = useCallback(async (file: File) => {
setUploadProgress(0);
try {
await driveService.uploadLocal(file, setUploadProgress);
Toast.success('上传成功');
loadFiles();
} catch {
Toast.error('上传失败');
} finally {
setUploadProgress(null);
}
return false; // prevent default upload
}, [loadFiles]);
const actionColumn = (record: IDriveFile) => (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{record.storageMode === 'local' ? (
<Button
size="small"
icon={<IconDownload />}
onClick={() => {
const a = document.createElement('a');
a.href = driveService.getDownloadUrl(record.id);
a.download = record.originalName;
a.click();
}}
>
</Button>
) : null}
<Button
size="small"
icon={<IconLink />}
onClick={() => {
setShareTarget(record);
setGeneratedLink('');
setShareDays('7');
setShareModalVisible(true);
}}
>
</Button>
{record.shareToken && (
<Popconfirm
title="撤销分享链接?"
onConfirm={() => void handleUnshare(record.id)}
okText="确定"
cancelText="取消"
>
<Button size="small" type="warning"></Button>
</Popconfirm>
)}
<Popconfirm
title="确认删除?"
onConfirm={() => void handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button size="small" type="danger" icon={<IconDelete />} />
</Popconfirm>
</div>
);
const columns: ColumnProps<IDriveFile>[] = [
{
title: '文件名',
dataIndex: 'originalName',
render: (text: string) => <Text ellipsis={{ showTooltip: true }} style={{ maxWidth: 200 }}>{text}</Text>,
},
{
title: '大小',
dataIndex: 'size',
width: 100,
render: (size: number) => formatSize(size),
},
{
title: '类型',
dataIndex: 'mimeType',
width: 140,
render: (type: string) => <Tag size="small">{type || '未知'}</Tag>,
},
{
title: '上传时间',
dataIndex: 'createtime',
width: 160,
},
{
title: '分享状态',
dataIndex: 'shareToken',
width: 90,
render: (token: string | null) =>
token ? <Tag color="green" size="small"></Tag> : <Tag size="small"></Tag>,
},
{
title: '操作',
width: 200,
render: (_: unknown, record: IDriveFile) => actionColumn(record),
},
];
const localFiles = files.filter((f) => f.storageMode === 'local');
const r2Files = files.filter((f) => !f.storageMode || f.storageMode === 'r2');
const renderTable = (data: IDriveFile[]) => {
if (loading) return <div style={{ textAlign: 'center', padding: 60 }}><Spin size="large" /></div>;
if (data.length === 0) return <Empty title="暂无文件" description="上传文件后将在此显示" style={{ paddingTop: 60 }} />;
return <Table columns={columns} dataSource={data} rowKey="id" pagination={{ pageSize: 20 }} />;
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Title heading={5} style={{ margin: 0 }}></Title>
<Button icon={<IconRefresh />} onClick={loadFiles}></Button>
</div>
<Tabs activeKey={activeTab} onChange={(k) => setActiveTab(k as 'local' | 'r2')}>
{/* 本地存储 Tab */}
<TabPane tab="本地存储" itemKey="local">
<div style={{ marginBottom: 12 }}>
<Upload
action=""
customRequest={({ file, onSuccess, onError }) => {
void handleLocalUpload(file as unknown as File).then(() => onSuccess?.({})).catch(() => onError?.({ status: 500 }));
}}
showUploadList={false}
>
<Button icon={<IconUpload />} theme="solid" type="primary"></Button>
</Upload>
{uploadProgress !== null && (
<div style={{ marginTop: 8, maxWidth: 300 }}>
<Progress percent={uploadProgress} showInfo size="small" />
</div>
)}
</div>
{renderTable(localFiles)}
</TabPane>
{/* R2 存储 Tab */}
<TabPane tab="R2 存储" itemKey="r2">
<div style={{ marginBottom: 12 }}>
{!r2PublicUrl && (
<div style={{
padding: '10px 16px',
marginBottom: 12,
background: 'var(--semi-color-warning-light-default)',
borderRadius: 6,
color: 'var(--semi-color-warning)',
}}>
R2 <strong> </strong> 使
</div>
)}
<Upload
action=""
customRequest={({ file, onSuccess, onError }) => {
Toast.info('上传功能需要配置 R2 存储,请先在设置页面配置 R2 信息');
onError?.({ status: 0 });
}}
showUploadList={false}
>
<Button icon={<IconUpload />} theme="solid" type="primary"> R2</Button>
</Upload>
</div>
{renderTable(r2Files)}
</TabPane>
</Tabs>
{/* Share Modal */}
<Modal
title="生成分享链接"
visible={shareModalVisible}
onCancel={() => setShareModalVisible(false)}
footer={null}
width={480}
>
<Text>{shareTarget?.originalName}</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '16px 0' }}>
<Text style={{ flexShrink: 0 }}>0=</Text>
<Input
value={shareDays}
onChange={setShareDays}
style={{ width: 100 }}
type="number"
/>
<Button theme="solid" type="primary" onClick={() => void handleShare()}>
</Button>
</div>
{generatedLink && (
<div>
<Input
value={generatedLink}
readonly
suffix={
<Button
size="small"
onClick={() => {
void navigator.clipboard.writeText(generatedLink);
Toast.success('已复制');
}}
>
</Button>
}
/>
<Text type="tertiary" size="small" style={{ marginTop: 6, display: 'block' }}>
</Text>
</div>
)}
</Modal>
</div>
);
}
+40 -44
View File
@@ -9,7 +9,6 @@ import {
Avatar,
Toast,
SideSheet,
Row,
List,
ButtonGroup,
Typography,
@@ -20,6 +19,8 @@ import {
IconArrowUp,
IconMenu,
IconCode,
IconMoon,
IconSun,
} from '@douyinfe/semi-icons';
import { IconDescriptions } from '@douyinfe/semi-icons-lab';
@@ -32,6 +33,8 @@ import authService from '../services/auth.service';
import NoteTree from '../components/sidebar/NoteTree';
import NoteEditor from '../components/note/NoteEditor';
import HomePage from './home/HomePage';
import SettingsPage from './settings/SettingsPage';
import DrivePage from './drive/DrivePage';
import DeleteModal from '../components/modals/DeleteModal';
import PasswordModal from '../components/modals/PasswordModal';
import TrashListModal from '../components/modals/TrashListModal';
@@ -60,15 +63,25 @@ export default function NotePage() {
activeView,
siderFlag,
sideVisible,
isDarkMode,
setActiveView,
setSiderFlag,
setSideVisible,
setDeleteModalVisible,
setUpdatePasswordModalVisible,
toggleDarkMode,
} = useUIStore();
const [trashList, setTrashList] = useState<INote[]>([]);
// Restore dark mode on mount
useEffect(() => {
if (isDarkMode) {
document.body.setAttribute('theme-mode', 'dark');
} else {
document.body.removeAttribute('theme-mode');
}
}, [isDarkMode]);
const loadTree = useCallback(() => {
noteService.getAllContent().then(
(res) => setTreeData(res.data),
@@ -96,6 +109,8 @@ export default function NotePage() {
(item: string) => {
if (item === '首页') {
setActiveView('home');
} else if (item === '网盘') {
setActiveView('drive');
} else if (item === '设置') {
setActiveView('settings');
} else if (item === '已删除') {
@@ -163,6 +178,13 @@ export default function NotePage() {
</SideSheet>
</>
)}
<Button
theme="borderless"
icon={isDarkMode ? <IconSun size="large" /> : <IconMoon size="large" />}
style={{ color: 'var(--semi-color-text-2)', marginRight: 12 }}
onClick={toggleDarkMode}
title={isDarkMode ? '切换浅色模式' : '切换深色模式'}
/>
<Button
theme="borderless"
icon={<IconBell size="large" />}
@@ -237,48 +259,22 @@ export default function NotePage() {
padding: 15,
}}
>
Settings
<hr />
<Row>
<Button
theme="solid"
type="secondary"
style={{ marginRight: 8 }}
onClick={() => setUpdatePasswordModalVisible(true)}
>
</Button>
</Row>
<br />
<Row>
<Button
theme="solid"
type="tertiary"
style={{ marginRight: 8 }}
onClick={() => {
void noteService
.historyQueryOrDelete(0)
.then((res) => Toast.info(`历史记录数:${String(res.data)}`));
}}
>
Count
</Button>
</Row>
<br />
<Row>
<Button
theme="solid"
type="danger"
style={{ marginRight: 8 }}
onClick={() => {
void noteService
.historyQueryOrDelete(1)
.then(() => Toast.success('历史记录已清空'));
}}
>
</Button>
</Row>
<SettingsPage />
</div>
</Content>
)}
{activeView === 'drive' && (
<Content style={{ padding: '10px', backgroundColor: 'var(--semi-color-bg-0)', overflowY: 'auto' }}>
<Breadcrumb style={{ marginBottom: 24 }} routes={['网盘']} />
<div
style={{
borderRadius: 10,
border: '1px solid var(--semi-color-border)',
padding: 15,
}}
>
<DrivePage />
</div>
</Content>
)}
@@ -0,0 +1,210 @@
import { useEffect, useState } from 'react';
import {
Tabs,
TabPane,
Card,
Input,
Button,
Toast,
Typography,
} from '@douyinfe/semi-ui';
import { useAuthStore } from '../../stores/useAuthStore';
import { useUIStore } from '../../stores/useUIStore';
import noteService from '../../services/note.service';
import settingsService from '../../services/settings.service';
const { Title, Text } = Typography;
export default function SettingsPage() {
const user = useAuthStore((s) => s.user);
const { isDarkMode, toggleDarkMode, setUpdatePasswordModalVisible } = useUIStore();
// 历史记录
const [historyCount, setHistoryCount] = useState<number | null>(null);
// R2 配置 — keys match backend snake_case
const [r2Config, setR2Config] = useState({
r2_endpoint: '',
r2_access_key_id: '',
r2_secret_access_key: '',
r2_bucket_name: '',
r2_public_url: '',
});
const [localStoragePath, setLocalStoragePath] = useState('');
useEffect(() => {
// Load history count
noteService
.historyQueryOrDelete(0)
.then((res) => setHistoryCount(res.data))
.catch(() => {});
// Load settings
settingsService
.getAll()
.then((res) => {
if (res.data) {
setR2Config({
r2_endpoint: res.data.r2_endpoint ?? '',
r2_access_key_id: res.data.r2_access_key_id ?? '',
r2_secret_access_key: res.data.r2_secret_access_key ?? '',
r2_bucket_name: res.data.r2_bucket_name ?? '',
r2_public_url: res.data.r2_public_url ?? '',
});
setLocalStoragePath(res.data.local_storage_path ?? '');
}
})
.catch(() => {});
}, []);
const handleClearHistory = () => {
noteService
.historyQueryOrDelete(1)
.then(() => {
Toast.success('历史记录已清空');
setHistoryCount(0);
})
.catch(() => Toast.error('清空失败'));
};
const handleSaveR2 = () => {
const payload: Record<string, string> = {};
for (const [k, v] of Object.entries(r2Config)) {
if (v) payload[k] = v;
}
settingsService
.set(payload)
.then(() => Toast.success('保存成功'))
.catch(() => Toast.error('保存失败'));
};
return (
<>
<Tabs type="line">
{/* 账户 */}
<TabPane tab="账户" itemKey="account">
<Card style={{ marginTop: 16 }}>
<Title heading={5} style={{ marginBottom: 16 }}>
</Title>
<Text>{user?.username ?? '—'}</Text>
<br />
<Text>{user?.email ?? '—'}</Text>
<br />
<br />
<Button
theme="solid"
type="secondary"
onClick={() => setUpdatePasswordModalVisible(true)}
>
</Button>
</Card>
</TabPane>
{/* 外观 */}
<TabPane tab="外观" itemKey="appearance">
<Card style={{ marginTop: 16 }}>
<Title heading={5} style={{ marginBottom: 16 }}>
</Title>
<Text>{isDarkMode ? '深色' : '浅色'}</Text>
<br />
<br />
<Button
theme="solid"
type={isDarkMode ? 'warning' : 'primary'}
onClick={toggleDarkMode}
>
{isDarkMode ? '浅色' : '深色'}
</Button>
</Card>
</TabPane>
{/* 历史记录 */}
<TabPane tab="历史记录" itemKey="history">
<Card style={{ marginTop: 16 }}>
<Title heading={5} style={{ marginBottom: 16 }}>
</Title>
<Text>{historyCount ?? '加载中…'}</Text>
<br />
<br />
<Button theme="solid" type="danger" onClick={handleClearHistory}>
</Button>
</Card>
</TabPane>
<TabPane tab="网盘配置 (R2)" itemKey="r2">
<Card style={{ marginTop: 16 }}>
<Title heading={5} style={{ marginBottom: 16 }}>
Cloudflare R2
</Title>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 500 }}>
{([
{ key: 'r2_endpoint' as const, label: 'R2 Endpoint', placeholder: 'https://xxxx.r2.cloudflarestorage.com' },
{ key: 'r2_access_key_id' as const, label: 'Access Key ID', placeholder: 'Access Key ID' },
{ key: 'r2_bucket_name' as const, label: 'Bucket Name', placeholder: 'my-bucket' },
{ key: 'r2_public_url' as const, label: 'Public URL(可选)', placeholder: 'https://pub-xxxx.r2.dev' },
]).map(({ key, label, placeholder }) => (
<div key={key} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text style={{ width: 160, flexShrink: 0 }}>{label}</Text>
<Input
placeholder={placeholder}
value={r2Config[key]}
onChange={(v) => setR2Config((c) => ({ ...c, [key]: v }))}
style={{ flex: 1 }}
/>
</div>
))}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text style={{ width: 160, flexShrink: 0 }}>Secret Access Key</Text>
<Input
type="password"
placeholder="Secret Access Key"
value={r2Config.r2_secret_access_key}
onChange={(v) => setR2Config((c) => ({ ...c, r2_secret_access_key: v }))}
style={{ flex: 1 }}
/>
</div>
<Button theme="solid" type="primary" onClick={handleSaveR2} style={{ alignSelf: 'flex-start' }}>
</Button>
</div>
</Card>
</TabPane>
{/* 本地存储配置 */}
<TabPane tab="本地存储配置" itemKey="local-storage">
<Card style={{ marginTop: 16 }}>
<Title heading={5} style={{ marginBottom: 8 }}></Title>
<Text type="tertiary" style={{ display: 'block', marginBottom: 16 }}>
使 <code>./local_drive</code>
</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, maxWidth: 500 }}>
<Input
placeholder="./local_drive"
value={localStoragePath}
onChange={setLocalStoragePath}
style={{ flex: 1 }}
/>
<Button
theme="solid"
type="primary"
onClick={() => {
settingsService
.set({ local_storage_path: localStoragePath })
.then(() => Toast.success('保存成功'))
.catch(() => Toast.error('保存失败'));
}}
>
</Button>
</div>
</Card>
</TabPane>
</Tabs>
</>
);
}
@@ -0,0 +1,47 @@
import axiosInstance from '../lib/axios';
import type { ApiResponse, IDriveFile } from '../types';
const driveService = {
list(): Promise<ApiResponse<IDriveFile[]>> {
return axiosInstance.get('drive/list').then((r) => r.data);
},
register(data: {
filename: string;
original_name: string;
size: number;
mime_type: string;
r2_key: string;
}): Promise<ApiResponse<void>> {
return axiosInstance.post('drive/register', data).then((r) => r.data);
},
uploadLocal(file: File, onProgress?: (percent: number) => void): Promise<ApiResponse<void>> {
const form = new FormData();
form.append('file', file);
return axiosInstance.post('drive/upload-local', form, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
if (onProgress && e.total) onProgress(Math.round((e.loaded * 100) / e.total));
},
}).then((r) => r.data);
},
getDownloadUrl(id: number): string {
return `/api/drive/download/${id}`;
},
share(id: number, days?: number): Promise<ApiResponse<{ share_token: string; share_expiry: string | null }>> {
return axiosInstance.post('drive/share', { id, days }).then((r) => r.data);
},
unshare(id: number): Promise<ApiResponse<void>> {
return axiosInstance.post('drive/unshare', { id }).then((r) => r.data);
},
delete(id: number): Promise<ApiResponse<void>> {
return axiosInstance.post('drive/delete', { id }).then((r) => r.data);
},
};
export default driveService;
+30 -3
View File
@@ -1,5 +1,5 @@
import axiosInstance from '../lib/axios';
import type { ApiResponse, INote, ITreeNode } from '../types';
import type { ApiResponse, IHistory, INote, ITreeNode } from '../types';
const API_URL = 'note/';
@@ -14,9 +14,9 @@ const noteService = {
.then((r) => r.data);
},
update(id: number, title: string, context: string): Promise<ApiResponse<void>> {
update(id: number, title: string, context: string, tags?: string): Promise<ApiResponse<void>> {
return axiosInstance
.post(API_URL + 'update', { id, title, context })
.post(API_URL + 'update', { id, title, context, tags: tags ?? '' })
.then((r) => r.data);
},
@@ -52,6 +52,33 @@ const noteService = {
.then((r) => r.data);
},
/**
* Move a note to a new parent.
*/
move(id: number, pid: number): Promise<ApiResponse<void>> {
return axiosInstance
.post(API_URL + 'move', { id, pid })
.then((r) => r.data);
},
/**
* Get revision history list for a note (metadata only, no content).
*/
getHistory(nid: number): Promise<ApiResponse<IHistory[]>> {
return axiosInstance
.get(API_URL + 'history', { params: { nid } })
.then((r) => r.data);
},
/**
* Get full content of a specific history entry.
*/
getHistoryContent(id: number): Promise<ApiResponse<IHistory>> {
return axiosInstance
.get(API_URL + 'history/content', { params: { id } })
.then((r) => r.data);
},
/**
* Fetch home list by type and optional search query.
* type: '0'=search, '1'=recently updated, '2'=newly added,
@@ -0,0 +1,13 @@
import axiosInstance from '../lib/axios';
import type { ApiResponse } from '../types';
const settingsService = {
getAll(): Promise<ApiResponse<Record<string, string | null>>> {
return axiosInstance.get('settings/all').then((r) => r.data);
},
set(data: Record<string, string>): Promise<ApiResponse<void>> {
return axiosInstance.post('settings/set', data).then((r) => r.data);
},
};
export default settingsService;
+13 -5
View File
@@ -5,30 +5,38 @@ interface NoteState {
currentNoteId: number | null;
currentTitle: string;
currentContent: string | null;
currentTags: string;
treeData: ITreeNode[];
homeData: INote[];
viewData: INote[];
setCurrentNote: (id: number, title: string, content: string) => void;
isDirty: boolean;
setCurrentNote: (id: number, title: string, content: string, tags?: string) => void;
setTreeData: (data: ITreeNode[]) => void;
setTitle: (title: string) => void;
setTags: (tags: string) => void;
setHomeData: (data: INote[]) => void;
setViewData: (data: INote[]) => void;
clearCurrentNote: () => void;
setDirty: (v: boolean) => void;
}
export const useNoteStore = create<NoteState>()((set) => ({
currentNoteId: null,
currentTitle: '',
currentContent: null,
currentTags: '',
treeData: [],
homeData: [],
viewData: [],
setCurrentNote: (id, title, content) =>
set({ currentNoteId: id, currentTitle: title, currentContent: content }),
isDirty: false,
setCurrentNote: (id, title, content, tags = '') =>
set({ currentNoteId: id, currentTitle: title, currentContent: content, currentTags: tags, isDirty: false }),
setTreeData: (treeData) => set({ treeData }),
setTitle: (title) => set({ currentTitle: title }),
setTitle: (title) => set({ currentTitle: title, isDirty: true }),
setTags: (tags) => set({ currentTags: tags, isDirty: true }),
setHomeData: (homeData) => set({ homeData }),
setViewData: (viewData) => set({ viewData }),
clearCurrentNote: () =>
set({ currentNoteId: null, currentTitle: '', currentContent: null }),
set({ currentNoteId: null, currentTitle: '', currentContent: null, currentTags: '', isDirty: false }),
setDirty: (isDirty) => set({ isDirty }),
}));
+15 -1
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand';
export type ActiveView = 'home' | 'note' | 'settings' | 'search';
export type ActiveView = 'home' | 'note' | 'settings' | 'search' | 'drive';
interface UIState {
activeView: ActiveView;
@@ -13,6 +13,7 @@ interface UIState {
deleteModalVisible: boolean;
updatePasswordModalVisible: boolean;
searchQuery: string;
isDarkMode: boolean;
setActiveView: (view: ActiveView) => void;
setSideVisible: (visible: boolean) => void;
setSiderFlag: (flag: boolean) => void;
@@ -22,6 +23,7 @@ interface UIState {
setDeleteModalVisible: (visible: boolean) => void;
setUpdatePasswordModalVisible: (visible: boolean) => void;
setSearchQuery: (query: string) => void;
toggleDarkMode: () => void;
}
export const useUIStore = create<UIState>()((set) => ({
@@ -35,6 +37,7 @@ export const useUIStore = create<UIState>()((set) => ({
deleteModalVisible: false,
updatePasswordModalVisible: false,
searchQuery: '',
isDarkMode: localStorage.getItem('theme') === 'dark',
setActiveView: (activeView) => set({ activeView }),
setSideVisible: (sideVisible) => set({ sideVisible }),
setSiderFlag: (siderFlag) => set({ siderFlag }),
@@ -47,4 +50,15 @@ export const useUIStore = create<UIState>()((set) => ({
setUpdatePasswordModalVisible: (updatePasswordModalVisible) =>
set({ updatePasswordModalVisible }),
setSearchQuery: (searchQuery) => set({ searchQuery }),
toggleDarkMode: () =>
set((state) => {
const next = !state.isDarkMode;
if (next) {
document.body.setAttribute('theme-mode', 'dark');
} else {
document.body.removeAttribute('theme-mode');
}
localStorage.setItem('theme', next ? 'dark' : 'light');
return { isDarkMode: next };
}),
}));
+27 -1
View File
@@ -21,17 +21,43 @@ export interface INote {
createtime: string;
updatetime: string;
viewtime: string;
flag: number;
tags?: string;
}
export interface ITreeNode {
id?: number;
pid?: number;
key: string;
key: string | number;
value?: string;
label: string;
children?: ITreeNode[];
}
export interface IDriveFile {
id: number;
filename: string;
originalName: string;
size: number | null;
mimeType: string | null;
shareToken: string | null;
shareExpiry: string | null;
userid: number;
createtime: string;
flag: number;
storageMode: 'r2' | 'local' | null;
localPath: string | null;
}
export interface IHistory {
id: number;
nid: number;
title: string;
context: string;
createtime: string;
flag: number;
}
export interface ApiResponse<T> {
code: number;
message: string;