From 6cd3aed5c53799db022af63d326be4c6d429d563 Mon Sep 17 00:00:00 2001 From: LeonspaceX Date: Fri, 30 Jan 2026 22:45:57 +0800 Subject: [PATCH] Add tag suggestions --- back/main.py | 33 ++++ front/src/api.ts | 32 ++++ front/src/components/CreatePost.tsx | 279 +++++++++++++++++++++++++++- 3 files changed, 341 insertions(+), 3 deletions(-) diff --git a/back/main.py b/back/main.py index a64b61a..829c9b8 100644 --- a/back/main.py +++ b/back/main.py @@ -706,6 +706,39 @@ def get_hot_topics(): return jsonify({"code": 1000, "data": {"list": data}}) except Exception as e: return jsonify({"code": 2003, "data": str(e)}) + +@app.route('/api/tag_suggest', methods=['GET']) +def tag_suggest(): + try: + prefix = request.args.get("prefix", "") + if prefix is None: + prefix = "" + prefix = str(prefix).strip().lstrip('#') + if not prefix: + return jsonify({"code": 1000, "data": {"list": []}}) + + limit = request.args.get("limit", 5, type=int) + if limit < 1: + limit = 1 + if limit > 10: + limit = 10 + + rows = db.session.query( + Hashtag.name, + db.func.count(Hashtag.name).label('count') + ).filter( + Hashtag.name.like(f"{prefix}%") + ).group_by( + Hashtag.name + ).order_by( + db.func.count(Hashtag.name).desc(), + Hashtag.name.asc() + ).limit(limit).all() + + data = [name for name, _ in rows] + return jsonify({"code": 1000, "data": {"list": data}}) + except Exception as e: + return jsonify({"code": 2003, "data": str(e)}) # --- 彩蛋 --- @app.route('/api/teapot', methods=['GET']) diff --git a/front/src/api.ts b/front/src/api.ts index 7fe063c..3fd9097 100644 --- a/front/src/api.ts +++ b/front/src/api.ts @@ -193,6 +193,38 @@ export const getHotTopics = async (): Promise => { } }; +export const getTagSuggest = async (prefix: string, limit: number = 5): Promise => { + const cleaned = prefix.trim().replace(/^#/, ''); + if (!cleaned) return []; + const cacheKey = `tag_suggest_${cleaned}`; + const cached = sessionStorage.getItem(cacheKey); + if (cached) { + try { + const parsed = JSON.parse(cached); + if (Array.isArray(parsed)) { + return parsed as string[]; + } + } catch { + // ignore cache error + } + } + try { + const response = await fetch(`/api/tag_suggest?prefix=${encodeURIComponent(cleaned)}&limit=${limit}`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const json = await response.json(); + if (json.code === 1000 && json.data && Array.isArray(json.data.list)) { + sessionStorage.setItem(cacheKey, JSON.stringify(json.data.list)); + return json.data.list as string[]; + } + throw new Error('Invalid response code or missing data'); + } catch (error) { + console.error('Failed to fetch tag suggest:', error); + throw error; + } +}; + const handlePostApiCode = (json: any) => { if (json && json.code === 2004) { notifyInvalidIdentity(); diff --git a/front/src/components/CreatePost.tsx b/front/src/components/CreatePost.tsx index 28a5107..d3a138e 100644 --- a/front/src/components/CreatePost.tsx +++ b/front/src/components/CreatePost.tsx @@ -32,7 +32,7 @@ import { } from '@fluentui/react-icons'; import { useNavigate } from 'react-router-dom'; import { useLayout } from '../context/LayoutContext'; -import { saveDraft, getDraft, createPost, uploadImage } from '../api'; +import { saveDraft, getDraft, createPost, uploadImage, getTagSuggest } from '../api'; const useStyles = makeStyles({ container: { @@ -49,12 +49,32 @@ const useStyles = makeStyles({ flexGrow: 1, minHeight: '400px', marginBottom: '20px', + position: 'relative', '& .w-md-editor': { height: '100% !important', boxShadow: tokens.shadow16, borderRadius: tokens.borderRadiusMedium, } }, + suggestBox: { + position: 'absolute', + left: tokens.spacingHorizontalM, + bottom: tokens.spacingVerticalM, + backgroundColor: tokens.colorNeutralBackground1, + borderRadius: tokens.borderRadiusMedium, + boxShadow: tokens.shadow16, + padding: tokens.spacingVerticalXS, + minWidth: '200px', + zIndex: 20, + }, + suggestItem: { + padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalS}`, + borderRadius: tokens.borderRadiusSmall, + cursor: 'pointer', + }, + suggestItemActive: { + backgroundColor: tokens.colorNeutralBackground2, + }, footer: { display: 'flex', justifyContent: 'space-between', @@ -86,6 +106,19 @@ const useStyles = makeStyles({ borderRadius: tokens.borderRadiusMedium, boxShadow: tokens.shadow16, zIndex: 10, + alignItems: 'center', + }, + tagSuggestBox: { + position: 'absolute', + left: 0, + bottom: '100%', + marginBottom: tokens.spacingVerticalXS, + backgroundColor: tokens.colorNeutralBackground1, + borderRadius: tokens.borderRadiusMedium, + boxShadow: tokens.shadow16, + padding: tokens.spacingVerticalXS, + minWidth: '200px', + zIndex: 30, }, tagButtonWrapper: { position: 'relative', @@ -142,16 +175,30 @@ const CreatePost: React.FC = () => { const [showImageDialog, setShowImageDialog] = useState(false); const [showUrlDialog, setShowUrlDialog] = useState(false); const [imageUrl, setImageUrl] = useState(""); + const [suggestions, setSuggestions] = useState([]); + const [showSuggest, setShowSuggest] = useState(false); + const [activeSuggest, setActiveSuggest] = useState(0); + const [tagSuggestions, setTagSuggestions] = useState([]); + const [showTagSuggest, setShowTagSuggest] = useState(false); + const [activeTagSuggest, setActiveTagSuggest] = useState(0); const tagInputRef = useRef(null); const tagButtonRef = useRef(null); const fileInputRef = useRef(null); const editorApiRef = useRef(null); + const textareaRef = useRef(null); const valueRef = useRef(""); const autoSaveIntervalRef = useRef(null); const lastInputAtRef = useRef(null); const activeElapsedRef = useRef(0); const hasStartedAutoSaveRef = useRef(false); + const suggestTimerRef = useRef(null); + const suggestPrefixRef = useRef(""); + const suggestRangeRef = useRef<{ start: number; end: number } | null>(null); + const tagSuggestTimerRef = useRef(null); + const tagSuggestPrefixRef = useRef(""); + const isComposingRef = useRef(false); + const isTagComposingRef = useRef(false); const handlePostSubmit = async () => { if (!value || !value.trim()) { @@ -390,6 +437,154 @@ const CreatePost: React.FC = () => { setShowTagInput(false); }; + const fetchTagSuggest = (prefix: string) => { + if (tagSuggestTimerRef.current) { + window.clearTimeout(tagSuggestTimerRef.current); + } + tagSuggestTimerRef.current = window.setTimeout(async () => { + try { + const list = await getTagSuggest(prefix, 5); + setTagSuggestions(list); + setActiveTagSuggest(0); + setShowTagSuggest(list.length > 0); + } catch { + setTagSuggestions([]); + setShowTagSuggest(false); + } + }, 250); + }; + + const applyTagSuggest = (tag: string) => { + const parts = tagInputValue.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) { + setTagInputValue(`#${tag}`); + } else { + parts[parts.length - 1] = `#${tag}`; + setTagInputValue(parts.join(' ')); + } + setShowTagSuggest(false); + }; + + const handleTagInputChange = (_: unknown, data: { value: string }) => { + const next = data.value; + setTagInputValue(next); + const parts = next.trim().split(/\s+/).filter(Boolean); + const last = parts[parts.length - 1] || ''; + const prefix = last.replace(/^#/, ''); + if (prefix.length < 2) { + setShowTagSuggest(false); + setTagSuggestions([]); + tagSuggestPrefixRef.current = ''; + return; + } + if (!prefix) { + setShowTagSuggest(false); + setTagSuggestions([]); + tagSuggestPrefixRef.current = ''; + return; + } + if (tagSuggestPrefixRef.current !== prefix) { + tagSuggestPrefixRef.current = prefix; + fetchTagSuggest(prefix); + } + }; + + const extractPrefixAtCursor = (text: string, cursor: number) => { + if (cursor <= 0) return null; + let i = cursor - 1; + while (i >= 0 && !/\s/.test(text[i])) { + if (text[i] === '#') break; + i -= 1; + } + if (i < 0 || text[i] !== '#') return null; + if (i > 0 && !/\s/.test(text[i - 1])) return null; + const prefix = text.slice(i + 1, cursor); + if (!prefix || /\s/.test(prefix)) return null; + return { prefix, start: i, end: cursor }; + }; + + const fetchSuggest = (prefix: string) => { + if (suggestTimerRef.current) { + window.clearTimeout(suggestTimerRef.current); + } + suggestTimerRef.current = window.setTimeout(async () => { + try { + const list = await getTagSuggest(prefix, 5); + setSuggestions(list); + setActiveSuggest(0); + setShowSuggest(list.length > 0); + } catch { + setSuggestions([]); + setShowSuggest(false); + } + }, 250); + }; + + const handleSuggestApply = (tag: string) => { + const range = suggestRangeRef.current; + if (!range) return; + const insert = `#${tag} `; + const before = valueRef.current.slice(0, range.start); + const after = valueRef.current.slice(range.end); + const next = `${before}${insert}${after}`; + setValue(next); + setShowSuggest(false); + setSuggestions([]); + const nextPos = range.start + insert.length; + window.setTimeout(() => { + const el = textareaRef.current; + if (!el) return; + el.focus(); + el.selectionStart = nextPos; + el.selectionEnd = nextPos; + }, 0); + }; + + const handleEditorKeyUp: React.KeyboardEventHandler = (e) => { + if (isComposingRef.current) return; + const el = e.currentTarget; + const cursor = el.selectionStart ?? 0; + const text = el.value ?? ''; + const info = extractPrefixAtCursor(text, cursor); + if (!info) { + setShowSuggest(false); + setSuggestions([]); + suggestPrefixRef.current = ''; + suggestRangeRef.current = null; + return; + } + suggestRangeRef.current = { start: info.start, end: info.end }; + if (info.prefix.length < 2) { + setShowSuggest(false); + setSuggestions([]); + suggestPrefixRef.current = ''; + return; + } + if (suggestPrefixRef.current !== info.prefix) { + suggestPrefixRef.current = info.prefix; + fetchSuggest(info.prefix); + } + }; + + const handleEditorKeyDown: React.KeyboardEventHandler = (e) => { + if (isComposingRef.current) return; + if (!showSuggest || suggestions.length === 0) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveSuggest(prev => (prev + 1) % suggestions.length); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveSuggest(prev => (prev - 1 + suggestions.length) % suggestions.length); + } else if (e.key === 'Enter') { + e.preventDefault(); + const tag = suggestions[activeSuggest]; + if (tag) handleSuggestApply(tag); + } else if (e.key === 'Escape') { + e.preventDefault(); + setShowSuggest(false); + } + }; + useEffect(() => { if (value === undefined) return; valueRef.current = value ?? ""; @@ -444,6 +639,19 @@ const CreatePost: React.FC = () => { height="100%" textareaProps={{ placeholder: "请在此输入投稿内容...", + onKeyUp: handleEditorKeyUp, + onKeyDown: handleEditorKeyDown, + onCompositionStart: () => { + isComposingRef.current = true; + }, + onCompositionEnd: (e: React.CompositionEvent) => { + isComposingRef.current = false; + // Trigger suggest after composition ends + handleEditorKeyUp(e as unknown as React.KeyboardEvent); + }, + ref: (el: HTMLTextAreaElement | null) => { + textareaRef.current = el; + }, }} commands={commands} extraCommands={getExtraCommands()} @@ -459,6 +667,22 @@ const CreatePost: React.FC = () => { } }} /> + {showSuggest && suggestions.length > 0 && ( +
+ {suggestions.map((item, idx) => ( +
{ + e.preventDefault(); + handleSuggestApply(item); + }} + > + #{item} +
+ ))} +
+ )}
@@ -475,13 +699,62 @@ const CreatePost: React.FC = () => {
setTagInputValue(data.value)} + onChange={handleTagInputChange} placeholder="输入tag..." onKeyDown={(e) => { - if (e.key === 'Enter') handleTagSubmit(); + if (isTagComposingRef.current) return; + if (showTagSuggest && tagSuggestions.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveTagSuggest(prev => (prev + 1) % tagSuggestions.length); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveTagSuggest(prev => (prev - 1 + tagSuggestions.length) % tagSuggestions.length); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + const tag = tagSuggestions[activeTagSuggest]; + if (tag) applyTagSuggest(tag); + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + setShowTagSuggest(false); + return; + } + } + if (e.key === 'Enter') { + handleTagSubmit(); + } + }} + onCompositionStart={() => { + isTagComposingRef.current = true; + }} + onCompositionEnd={(e) => { + isTagComposingRef.current = false; + handleTagInputChange(e, { value: (e.target as HTMLInputElement).value }); }} /> + {showTagSuggest && tagSuggestions.length > 0 && ( +
+ {tagSuggestions.map((item, idx) => ( +
{ + e.preventDefault(); + applyTagSuggest(item); + }} + > + #{item} +
+ ))} +
+ )}
)}