Add storage
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user