1321
This commit is contained in:
@@ -81,3 +81,11 @@ export const getStatics = async (): Promise<StaticsData> => {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const saveDraft = (content: string): void => {
|
||||||
|
localStorage.setItem('draft', content);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDraft = (): string | null => {
|
||||||
|
return localStorage.getItem('draft');
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// 我草,react-md-editor这么好用,无语了,早知道v1也用这个编辑器了.....
|
// 我草,react-md-editor这么好用,无语了,早知道v1也用这个编辑器了.....
|
||||||
// 不过居然没有汉化...有点可惜
|
// 不过居然没有汉化...有点可惜
|
||||||
// 我撤回刚才那句话,编辑器有提供中文指令集,我是sb。
|
// 我撤回刚才那句话,编辑器有提供中文指令集,我是sb。
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import MDEditor from '@uiw/react-md-editor';
|
import MDEditor from '@uiw/react-md-editor';
|
||||||
// 引入中文指令集
|
// 引入中文指令集
|
||||||
import { getCommands, getExtraCommands } from '@uiw/react-md-editor/commands-cn';
|
import { getCommands, getExtraCommands } from '@uiw/react-md-editor/commands-cn';
|
||||||
@@ -13,7 +13,10 @@ import {
|
|||||||
makeStyles,
|
makeStyles,
|
||||||
shorthands,
|
shorthands,
|
||||||
tokens,
|
tokens,
|
||||||
Input
|
Input,
|
||||||
|
useToastController,
|
||||||
|
Toast,
|
||||||
|
ToastTitle
|
||||||
} from '@fluentui/react-components';
|
} from '@fluentui/react-components';
|
||||||
import {
|
import {
|
||||||
NumberSymbol24Regular,
|
NumberSymbol24Regular,
|
||||||
@@ -21,6 +24,7 @@ import {
|
|||||||
Save24Regular
|
Save24Regular
|
||||||
} from '@fluentui/react-icons';
|
} from '@fluentui/react-icons';
|
||||||
import { useLayout } from '../context/LayoutContext';
|
import { useLayout } from '../context/LayoutContext';
|
||||||
|
import { saveDraft, getDraft } from '../api';
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
container: {
|
container: {
|
||||||
@@ -114,14 +118,82 @@ const remarkTagPlugin = () => {
|
|||||||
|
|
||||||
const CreatePost: React.FC = () => {
|
const CreatePost: React.FC = () => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const { isDarkMode } = useLayout();
|
const { isDarkMode, toasterId } = useLayout();
|
||||||
|
const { dispatchToast } = useToastController(toasterId);
|
||||||
const [value, setValue] = useState<string | undefined>("");
|
const [value, setValue] = useState<string | undefined>("");
|
||||||
const [lastSaved, setLastSaved] = useState<string>("");
|
const [lastSaved, setLastSaved] = useState<string>(() => new Date().toLocaleTimeString('zh-CN', { hour12: false }));
|
||||||
|
|
||||||
// 标签输入相关状态
|
// 标签输入相关状态
|
||||||
const [showTagInput, setShowTagInput] = useState(false);
|
const [showTagInput, setShowTagInput] = useState(false);
|
||||||
const [tagInputValue, setTagInputValue] = useState("");
|
const [tagInputValue, setTagInputValue] = useState("");
|
||||||
|
|
||||||
|
const tagInputRef = useRef<HTMLDivElement>(null);
|
||||||
|
const tagButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const valueRef = useRef<string>("");
|
||||||
|
const autoSaveIntervalRef = useRef<number | null>(null);
|
||||||
|
const lastInputAtRef = useRef<number | null>(null);
|
||||||
|
const activeElapsedRef = useRef<number>(0);
|
||||||
|
const hasStartedAutoSaveRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
showTagInput &&
|
||||||
|
tagInputRef.current &&
|
||||||
|
!tagInputRef.current.contains(event.target as Node) &&
|
||||||
|
tagButtonRef.current &&
|
||||||
|
!tagButtonRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setShowTagInput(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [showTagInput]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const draft = getDraft();
|
||||||
|
if (draft) {
|
||||||
|
setValue(draft);
|
||||||
|
dispatchToast(
|
||||||
|
<Toast>
|
||||||
|
<ToastTitle>读取草稿成功!</ToastTitle>
|
||||||
|
</Toast>,
|
||||||
|
{ intent: 'success' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [dispatchToast]);
|
||||||
|
|
||||||
|
const formatTime = () => new Date().toLocaleTimeString('zh-CN', { hour12: false });
|
||||||
|
|
||||||
|
const persistDraft = useCallback(() => {
|
||||||
|
saveDraft(valueRef.current);
|
||||||
|
setLastSaved(formatTime());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startAutoSaveInterval = useCallback(() => {
|
||||||
|
if (autoSaveIntervalRef.current !== null) return;
|
||||||
|
autoSaveIntervalRef.current = window.setInterval(() => {
|
||||||
|
const lastInputAt = lastInputAtRef.current;
|
||||||
|
if (!lastInputAt) return;
|
||||||
|
if (Date.now() - lastInputAt <= 3000) {
|
||||||
|
activeElapsedRef.current += 1000;
|
||||||
|
if (activeElapsedRef.current >= 30000) {
|
||||||
|
if (autoSaveIntervalRef.current !== null) {
|
||||||
|
window.clearInterval(autoSaveIntervalRef.current);
|
||||||
|
}
|
||||||
|
autoSaveIntervalRef.current = null;
|
||||||
|
activeElapsedRef.current = 0;
|
||||||
|
hasStartedAutoSaveRef.current = false;
|
||||||
|
persistDraft();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}, [persistDraft]);
|
||||||
|
|
||||||
const handleTagSubmit = () => {
|
const handleTagSubmit = () => {
|
||||||
if (!tagInputValue.trim()) {
|
if (!tagInputValue.trim()) {
|
||||||
setShowTagInput(false);
|
setShowTagInput(false);
|
||||||
@@ -146,9 +218,24 @@ const CreatePost: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 模拟自动保存时间显示
|
if (value === undefined) return;
|
||||||
const now = new Date();
|
valueRef.current = value ?? "";
|
||||||
setLastSaved(now.toLocaleTimeString('zh-CN', { hour12: false }));
|
if (value.length === 0 && !hasStartedAutoSaveRef.current) return;
|
||||||
|
|
||||||
|
lastInputAtRef.current = Date.now();
|
||||||
|
if (!hasStartedAutoSaveRef.current) {
|
||||||
|
hasStartedAutoSaveRef.current = true;
|
||||||
|
persistDraft();
|
||||||
|
}
|
||||||
|
startAutoSaveInterval();
|
||||||
|
}, [value, persistDraft, startAutoSaveInterval]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (autoSaveIntervalRef.current !== null) {
|
||||||
|
window.clearInterval(autoSaveIntervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -188,11 +275,11 @@ const CreatePost: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
<div className={styles.tagButtonWrapper}>
|
<div className={styles.tagButtonWrapper}>
|
||||||
{showTagInput && (
|
{showTagInput && (
|
||||||
<div className={styles.tagInputContainer}>
|
<div className={styles.tagInputContainer} ref={tagInputRef}>
|
||||||
<Input
|
<Input
|
||||||
value={tagInputValue}
|
value={tagInputValue}
|
||||||
onChange={(e, data) => setTagInputValue(data.value)}
|
onChange={(_, data) => setTagInputValue(data.value)}
|
||||||
placeholder="输入标签..."
|
placeholder="输入tag..."
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') handleTagSubmit();
|
if (e.key === 'Enter') handleTagSubmit();
|
||||||
}}
|
}}
|
||||||
@@ -201,6 +288,7 @@ const CreatePost: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
ref={tagButtonRef}
|
||||||
appearance="subtle"
|
appearance="subtle"
|
||||||
icon={<NumberSymbol24Regular />}
|
icon={<NumberSymbol24Regular />}
|
||||||
onClick={() => setShowTagInput(!showTagInput)}
|
onClick={() => setShowTagInput(!showTagInput)}
|
||||||
@@ -217,6 +305,15 @@ const CreatePost: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
appearance="outline"
|
appearance="outline"
|
||||||
icon={<Save24Regular />}
|
icon={<Save24Regular />}
|
||||||
|
onClick={() => {
|
||||||
|
persistDraft();
|
||||||
|
dispatchToast(
|
||||||
|
<Toast>
|
||||||
|
<ToastTitle>保存草稿成功!</ToastTitle>
|
||||||
|
</Toast>,
|
||||||
|
{ intent: 'success' }
|
||||||
|
);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
保存草稿
|
保存草稿
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ import {
|
|||||||
Spinner,
|
Spinner,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
|
useToastController,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
} from '@fluentui/react-components';
|
} from '@fluentui/react-components';
|
||||||
import {
|
import {
|
||||||
CheckmarkCircle20Filled,
|
CheckmarkCircle20Filled,
|
||||||
DismissCircle20Filled,
|
DismissCircle20Filled,
|
||||||
ArrowClockwise20Regular
|
ArrowClockwise20Regular
|
||||||
} from '@fluentui/react-icons';
|
} from '@fluentui/react-icons';
|
||||||
|
import { useLayout } from '../context/LayoutContext';
|
||||||
import { testApiStatus, getStatics } from '../api';
|
import { testApiStatus, getStatics } from '../api';
|
||||||
import type { StaticsData } from '../api';
|
import type { StaticsData } from '../api';
|
||||||
|
|
||||||
@@ -71,6 +75,8 @@ const useStyles = makeStyles({
|
|||||||
|
|
||||||
const StatusDisplay: React.FC = () => {
|
const StatusDisplay: React.FC = () => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
|
const { toasterId } = useLayout();
|
||||||
|
const { dispatchToast } = useToastController(toasterId);
|
||||||
const [isApiOnline, setIsApiOnline] = useState<boolean | null>(null);
|
const [isApiOnline, setIsApiOnline] = useState<boolean | null>(null);
|
||||||
const [statics, setStatics] = useState<StaticsData | null>(null);
|
const [statics, setStatics] = useState<StaticsData | null>(null);
|
||||||
const [isTestLoading, setIsTestLoading] = useState<boolean>(true);
|
const [isTestLoading, setIsTestLoading] = useState<boolean>(true);
|
||||||
@@ -80,23 +86,31 @@ const StatusDisplay: React.FC = () => {
|
|||||||
setIsApiOnline(online);
|
setIsApiOnline(online);
|
||||||
setIsTestLoading(false);
|
setIsTestLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
const refreshStatics = useCallback(async () => {
|
const refreshStatics = useCallback(async (isManual: boolean = false) => {
|
||||||
setIsStaticsLoading(true);
|
setIsStaticsLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await getStatics();
|
const data = await getStatics();
|
||||||
setStatics(data);
|
setStatics(data);
|
||||||
|
if (isManual) {
|
||||||
|
dispatchToast(
|
||||||
|
<Toast>
|
||||||
|
<ToastTitle>统计数据已刷新</ToastTitle>
|
||||||
|
</Toast>,
|
||||||
|
{ intent: 'success' }
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to refresh statics:', error);
|
console.error('Failed to refresh statics:', error);
|
||||||
setStatics(null); // 失败时重置数据
|
setStatics(null); // 失败时重置数据
|
||||||
} finally {
|
} finally {
|
||||||
setIsStaticsLoading(false);
|
setIsStaticsLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [dispatchToast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 初始加载
|
// 初始加载
|
||||||
checkStatus();
|
checkStatus();
|
||||||
refreshStatics();
|
refreshStatics(false);
|
||||||
|
|
||||||
// 每 10 秒测试一次后端 API 状态
|
// 每 10 秒测试一次后端 API 状态
|
||||||
const testInterval = setInterval(checkStatus, 10000);
|
const testInterval = setInterval(checkStatus, 10000);
|
||||||
@@ -141,7 +155,7 @@ const StatusDisplay: React.FC = () => {
|
|||||||
className={styles.refreshButton}
|
className={styles.refreshButton}
|
||||||
appearance="subtle"
|
appearance="subtle"
|
||||||
icon={<ArrowClockwise20Regular fontSize={16} />}
|
icon={<ArrowClockwise20Regular fontSize={16} />}
|
||||||
onClick={refreshStatics}
|
onClick={() => refreshStatics(true)}
|
||||||
disabled={isStaticsLoading}
|
disabled={isStaticsLoading}
|
||||||
title="刷新统计数据"
|
title="刷新统计数据"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface LayoutContextType {
|
|||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
isDarkMode: boolean;
|
isDarkMode: boolean;
|
||||||
toggleTheme: () => void;
|
toggleTheme: () => void;
|
||||||
|
toasterId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayoutContext = createContext<LayoutContextType | undefined>(undefined);
|
const LayoutContext = createContext<LayoutContextType | undefined>(undefined);
|
||||||
@@ -48,7 +49,7 @@ export const LayoutProvider: React.FC<{ children: React.ReactNode; toasterId: st
|
|||||||
const toggleTheme = () => setIsDarkMode(prev => !prev);
|
const toggleTheme = () => setIsDarkMode(prev => !prev);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LayoutContext.Provider value={{ settings, isSidebarCollapsed, toggleSidebar, isDarkMode, toggleTheme }}>
|
<LayoutContext.Provider value={{ settings, isSidebarCollapsed, toggleSidebar, isDarkMode, toggleTheme, toasterId }}>
|
||||||
{children}
|
{children}
|
||||||
</LayoutContext.Provider>
|
</LayoutContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user