Add tag suggestions
This commit is contained in:
33
back/main.py
33
back/main.py
@@ -707,6 +707,39 @@ def get_hot_topics():
|
||||
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'])
|
||||
def return_418():
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user