From 50371f9a2e80e43d03a2698f944d09c5343ad52c Mon Sep 17 00:00:00 2001 From: Leonxie Date: Fri, 23 Jan 2026 13:22:07 +0800 Subject: [PATCH] 1321 --- front/src/api.ts | 8 ++ front/src/components/CreatePost.tsx | 117 ++++++++++++++++++++++--- front/src/components/StatusDisplay.tsx | 22 ++++- front/src/context/LayoutContext.tsx | 3 +- 4 files changed, 135 insertions(+), 15 deletions(-) diff --git a/front/src/api.ts b/front/src/api.ts index ef03642..e9ad41e 100644 --- a/front/src/api.ts +++ b/front/src/api.ts @@ -81,3 +81,11 @@ export const getStatics = async (): Promise => { throw error; } }; + +export const saveDraft = (content: string): void => { + localStorage.setItem('draft', content); +}; + +export const getDraft = (): string | null => { + return localStorage.getItem('draft'); +}; diff --git a/front/src/components/CreatePost.tsx b/front/src/components/CreatePost.tsx index 18df702..b270c2d 100644 --- a/front/src/components/CreatePost.tsx +++ b/front/src/components/CreatePost.tsx @@ -1,7 +1,7 @@ // 我草,react-md-editor这么好用,无语了,早知道v1也用这个编辑器了..... // 不过居然没有汉化...有点可惜 // 我撤回刚才那句话,编辑器有提供中文指令集,我是sb。 -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import MDEditor from '@uiw/react-md-editor'; // 引入中文指令集 import { getCommands, getExtraCommands } from '@uiw/react-md-editor/commands-cn'; @@ -13,7 +13,10 @@ import { makeStyles, shorthands, tokens, - Input + Input, + useToastController, + Toast, + ToastTitle } from '@fluentui/react-components'; import { NumberSymbol24Regular, @@ -21,6 +24,7 @@ import { Save24Regular } from '@fluentui/react-icons'; import { useLayout } from '../context/LayoutContext'; +import { saveDraft, getDraft } from '../api'; const useStyles = makeStyles({ container: { @@ -114,14 +118,82 @@ const remarkTagPlugin = () => { const CreatePost: React.FC = () => { const styles = useStyles(); - const { isDarkMode } = useLayout(); + const { isDarkMode, toasterId } = useLayout(); + const { dispatchToast } = useToastController(toasterId); const [value, setValue] = useState(""); - const [lastSaved, setLastSaved] = useState(""); + const [lastSaved, setLastSaved] = useState(() => new Date().toLocaleTimeString('zh-CN', { hour12: false })); // 标签输入相关状态 const [showTagInput, setShowTagInput] = useState(false); const [tagInputValue, setTagInputValue] = useState(""); + const tagInputRef = useRef(null); + const tagButtonRef = useRef(null); + const valueRef = useRef(""); + const autoSaveIntervalRef = useRef(null); + const lastInputAtRef = useRef(null); + const activeElapsedRef = useRef(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( + + 读取草稿成功! + , + { 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 = () => { if (!tagInputValue.trim()) { setShowTagInput(false); @@ -146,9 +218,24 @@ const CreatePost: React.FC = () => { }; useEffect(() => { - // 模拟自动保存时间显示 - const now = new Date(); - setLastSaved(now.toLocaleTimeString('zh-CN', { hour12: false })); + if (value === undefined) return; + valueRef.current = value ?? ""; + 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 ( @@ -188,11 +275,11 @@ const CreatePost: React.FC = () => {
{showTagInput && ( -
+
setTagInputValue(data.value)} - placeholder="输入标签..." + onChange={(_, data) => setTagInputValue(data.value)} + placeholder="输入tag..." onKeyDown={(e) => { if (e.key === 'Enter') handleTagSubmit(); }} @@ -201,6 +288,7 @@ const CreatePost: React.FC = () => {
)} diff --git a/front/src/components/StatusDisplay.tsx b/front/src/components/StatusDisplay.tsx index 829964f..00d04e5 100644 --- a/front/src/components/StatusDisplay.tsx +++ b/front/src/components/StatusDisplay.tsx @@ -8,12 +8,16 @@ import { Spinner, Badge, Button, + useToastController, + Toast, + ToastTitle, } from '@fluentui/react-components'; import { CheckmarkCircle20Filled, DismissCircle20Filled, ArrowClockwise20Regular } from '@fluentui/react-icons'; +import { useLayout } from '../context/LayoutContext'; import { testApiStatus, getStatics } from '../api'; import type { StaticsData } from '../api'; @@ -71,6 +75,8 @@ const useStyles = makeStyles({ const StatusDisplay: React.FC = () => { const styles = useStyles(); + const { toasterId } = useLayout(); + const { dispatchToast } = useToastController(toasterId); const [isApiOnline, setIsApiOnline] = useState(null); const [statics, setStatics] = useState(null); const [isTestLoading, setIsTestLoading] = useState(true); @@ -80,23 +86,31 @@ const StatusDisplay: React.FC = () => { setIsApiOnline(online); setIsTestLoading(false); }, []); - const refreshStatics = useCallback(async () => { + const refreshStatics = useCallback(async (isManual: boolean = false) => { setIsStaticsLoading(true); try { const data = await getStatics(); setStatics(data); + if (isManual) { + dispatchToast( + + 统计数据已刷新 + , + { intent: 'success' } + ); + } } catch (error) { console.error('Failed to refresh statics:', error); setStatics(null); // 失败时重置数据 } finally { setIsStaticsLoading(false); } - }, []); + }, [dispatchToast]); useEffect(() => { // 初始加载 checkStatus(); - refreshStatics(); + refreshStatics(false); // 每 10 秒测试一次后端 API 状态 const testInterval = setInterval(checkStatus, 10000); @@ -141,7 +155,7 @@ const StatusDisplay: React.FC = () => { className={styles.refreshButton} appearance="subtle" icon={} - onClick={refreshStatics} + onClick={() => refreshStatics(true)} disabled={isStaticsLoading} title="刷新统计数据" /> diff --git a/front/src/context/LayoutContext.tsx b/front/src/context/LayoutContext.tsx index 8d84bea..503af22 100644 --- a/front/src/context/LayoutContext.tsx +++ b/front/src/context/LayoutContext.tsx @@ -9,6 +9,7 @@ interface LayoutContextType { toggleSidebar: () => void; isDarkMode: boolean; toggleTheme: () => void; + toasterId: string; } const LayoutContext = createContext(undefined); @@ -48,7 +49,7 @@ export const LayoutProvider: React.FC<{ children: React.ReactNode; toasterId: st const toggleTheme = () => setIsDarkMode(prev => !prev); return ( - + {children} );