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:
|
except Exception as e:
|
||||||
return jsonify({"code": 2003, "data": str(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'])
|
@app.route('/api/teapot', methods=['GET'])
|
||||||
def return_418():
|
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) => {
|
const handlePostApiCode = (json: any) => {
|
||||||
if (json && json.code === 2004) {
|
if (json && json.code === 2004) {
|
||||||
notifyInvalidIdentity();
|
notifyInvalidIdentity();
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
} from '@fluentui/react-icons';
|
} from '@fluentui/react-icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useLayout } from '../context/LayoutContext';
|
import { useLayout } from '../context/LayoutContext';
|
||||||
import { saveDraft, getDraft, createPost, uploadImage } from '../api';
|
import { saveDraft, getDraft, createPost, uploadImage, getTagSuggest } from '../api';
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
container: {
|
container: {
|
||||||
@@ -49,12 +49,32 @@ const useStyles = makeStyles({
|
|||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
minHeight: '400px',
|
minHeight: '400px',
|
||||||
marginBottom: '20px',
|
marginBottom: '20px',
|
||||||
|
position: 'relative',
|
||||||
'& .w-md-editor': {
|
'& .w-md-editor': {
|
||||||
height: '100% !important',
|
height: '100% !important',
|
||||||
boxShadow: tokens.shadow16,
|
boxShadow: tokens.shadow16,
|
||||||
borderRadius: tokens.borderRadiusMedium,
|
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: {
|
footer: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
@@ -86,6 +106,19 @@ const useStyles = makeStyles({
|
|||||||
borderRadius: tokens.borderRadiusMedium,
|
borderRadius: tokens.borderRadiusMedium,
|
||||||
boxShadow: tokens.shadow16,
|
boxShadow: tokens.shadow16,
|
||||||
zIndex: 10,
|
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: {
|
tagButtonWrapper: {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -142,16 +175,30 @@ const CreatePost: React.FC = () => {
|
|||||||
const [showImageDialog, setShowImageDialog] = useState(false);
|
const [showImageDialog, setShowImageDialog] = useState(false);
|
||||||
const [showUrlDialog, setShowUrlDialog] = useState(false);
|
const [showUrlDialog, setShowUrlDialog] = useState(false);
|
||||||
const [imageUrl, setImageUrl] = useState("");
|
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 tagInputRef = useRef<HTMLDivElement>(null);
|
||||||
const tagButtonRef = useRef<HTMLButtonElement>(null);
|
const tagButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const editorApiRef = useRef<any>(null);
|
const editorApiRef = useRef<any>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const valueRef = useRef<string>("");
|
const valueRef = useRef<string>("");
|
||||||
const autoSaveIntervalRef = useRef<number | null>(null);
|
const autoSaveIntervalRef = useRef<number | null>(null);
|
||||||
const lastInputAtRef = useRef<number | null>(null);
|
const lastInputAtRef = useRef<number | null>(null);
|
||||||
const activeElapsedRef = useRef<number>(0);
|
const activeElapsedRef = useRef<number>(0);
|
||||||
const hasStartedAutoSaveRef = useRef(false);
|
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 () => {
|
const handlePostSubmit = async () => {
|
||||||
if (!value || !value.trim()) {
|
if (!value || !value.trim()) {
|
||||||
@@ -390,6 +437,154 @@ const CreatePost: React.FC = () => {
|
|||||||
setShowTagInput(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (value === undefined) return;
|
if (value === undefined) return;
|
||||||
valueRef.current = value ?? "";
|
valueRef.current = value ?? "";
|
||||||
@@ -444,6 +639,19 @@ const CreatePost: React.FC = () => {
|
|||||||
height="100%"
|
height="100%"
|
||||||
textareaProps={{
|
textareaProps={{
|
||||||
placeholder: "请在此输入投稿内容...",
|
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}
|
commands={commands}
|
||||||
extraCommands={getExtraCommands()}
|
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>
|
||||||
|
|
||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
@@ -475,13 +699,62 @@ const CreatePost: React.FC = () => {
|
|||||||
<div className={styles.tagInputContainer} ref={tagInputRef}>
|
<div className={styles.tagInputContainer} ref={tagInputRef}>
|
||||||
<Input
|
<Input
|
||||||
value={tagInputValue}
|
value={tagInputValue}
|
||||||
onChange={(_, data) => setTagInputValue(data.value)}
|
onChange={handleTagInputChange}
|
||||||
placeholder="输入tag..."
|
placeholder="输入tag..."
|
||||||
onKeyDown={(e) => {
|
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>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
Reference in New Issue
Block a user