Add tag suggestions

This commit is contained in:
LeonspaceX
2026-01-30 22:45:57 +08:00
parent 8f6ae3fdfc
commit 6cd3aed5c5
3 changed files with 341 additions and 3 deletions

View File

@@ -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'])

View File

@@ -193,6 +193,38 @@ export const getHotTopics = async (): Promise<HotTopicItem[]> => {
}
};
export const getTagSuggest = async (prefix: string, limit: number = 5): Promise<string[]> => {
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();

View File

@@ -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<string[]>([]);
const [showSuggest, setShowSuggest] = useState(false);
const [activeSuggest, setActiveSuggest] = useState(0);
const [tagSuggestions, setTagSuggestions] = useState<string[]>([]);
const [showTagSuggest, setShowTagSuggest] = useState(false);
const [activeTagSuggest, setActiveTagSuggest] = useState(0);
const tagInputRef = useRef<HTMLDivElement>(null);
const tagButtonRef = useRef<HTMLButtonElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const editorApiRef = useRef<any>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(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);
const suggestTimerRef = useRef<number | null>(null);
const suggestPrefixRef = useRef<string>("");
const suggestRangeRef = useRef<{ start: number; end: number } | null>(null);
const tagSuggestTimerRef = useRef<number | null>(null);
const tagSuggestPrefixRef = useRef<string>("");
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<HTMLTextAreaElement> = (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<HTMLTextAreaElement> = (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<HTMLTextAreaElement>) => {
isComposingRef.current = false;
// Trigger suggest after composition ends
handleEditorKeyUp(e as unknown as React.KeyboardEvent<HTMLTextAreaElement>);
},
ref: (el: HTMLTextAreaElement | null) => {
textareaRef.current = el;
},
}}
commands={commands}
extraCommands={getExtraCommands()}
@@ -459,6 +667,22 @@ const CreatePost: React.FC = () => {
}
}}
/>
{showSuggest && suggestions.length > 0 && (
<div className={styles.suggestBox}>
{suggestions.map((item, idx) => (
<div
key={`${item}-${idx}`}
className={`${styles.suggestItem} ${idx === activeSuggest ? styles.suggestItemActive : ''}`}
onMouseDown={(e) => {
e.preventDefault();
handleSuggestApply(item);
}}
>
#{item}
</div>
))}
</div>
)}
</div>
<div className={styles.footer}>
@@ -475,13 +699,62 @@ const CreatePost: React.FC = () => {
<div className={styles.tagInputContainer} ref={tagInputRef}>
<Input
value={tagInputValue}
onChange={(_, data) => 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 });
}}
/>
<Button appearance="primary" size="small" onClick={handleTagSubmit}>OK</Button>
{showTagSuggest && tagSuggestions.length > 0 && (
<div className={styles.tagSuggestBox}>
{tagSuggestions.map((item, idx) => (
<div
key={`${item}-${idx}`}
className={`${styles.suggestItem} ${idx === activeTagSuggest ? styles.suggestItemActive : ''}`}
onMouseDown={(e) => {
e.preventDefault();
applyTagSuggest(item);
}}
>
#{item}
</div>
))}
</div>
)}
</div>
)}
<Button