Files
memind_adm/src/admin/pages/BlockedWordsPage.tsx
T
2026-06-30 20:26:33 +08:00

319 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useState } from 'react';
import {
listBlockedWords,
createBlockedWord,
updateBlockedWord,
deleteBlockedWord,
} from '../../api/client';
import type { BlockedWord } from '../../types';
const DEFAULT_WORDS = ['goose', 'aider', 'openhands'];
export function BlockedWordsPage() {
const [words, setWords] = useState<BlockedWord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [newWord, setNewWord] = useState('');
const [newReplacement, setNewReplacement] = useState('***');
const [newNote, setNewNote] = useState('');
const [adding, setAdding] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [editWord, setEditWord] = useState('');
const [editReplacement, setEditReplacement] = useState('');
const [editNote, setEditNote] = useState('');
const [editBusy, setEditBusy] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setWords(await listBlockedWords());
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void load(); }, [load]);
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!newWord.trim()) return;
setAdding(true);
setError(null);
setMessage(null);
try {
const created = await createBlockedWord({
word: newWord.trim(),
replacement: newReplacement || '***',
note: newNote.trim() || undefined,
});
setWords((prev) => [created, ...prev]);
setNewWord('');
setNewReplacement('***');
setNewNote('');
setMessage(`已添加:${created.word}`);
} catch (err) {
setError(err instanceof Error ? err.message : '添加失败');
} finally {
setAdding(false);
}
};
const startEdit = (w: BlockedWord) => {
setEditId(w.id);
setEditWord(w.word);
setEditReplacement(w.replacement);
setEditNote(w.note ?? '');
};
const cancelEdit = () => setEditId(null);
const handleSaveEdit = async (id: string) => {
setEditBusy(true);
setError(null);
setMessage(null);
try {
const updated = await updateBlockedWord(id, {
word: editWord.trim(),
replacement: editReplacement || '***',
note: editNote.trim() || '',
});
setWords((prev) => prev.map((w) => (w.id === id ? updated : w)));
setEditId(null);
setMessage('已更新');
} catch (err) {
setError(err instanceof Error ? err.message : '更新失败');
} finally {
setEditBusy(false);
}
};
const handleToggleStatus = async (w: BlockedWord) => {
setError(null);
try {
const updated = await updateBlockedWord(w.id, {
status: w.status === 'active' ? 'disabled' : 'active',
});
setWords((prev) => prev.map((item) => (item.id === w.id ? updated : item)));
} catch (err) {
setError(err instanceof Error ? err.message : '状态切换失败');
}
};
const handleDelete = async (id: string, word: string) => {
if (!window.confirm(`确认删除词语「${word}」?`)) return;
setError(null);
try {
await deleteBlockedWord(id);
setWords((prev) => prev.filter((w) => w.id !== id));
setMessage(`已删除:${word}`);
} catch (err) {
setError(err instanceof Error ? err.message : '删除失败');
}
};
const handleQuickAdd = async (word: string) => {
setError(null);
setMessage(null);
try {
const created = await createBlockedWord({ word, replacement: '***' });
setWords((prev) => [created, ...prev]);
setMessage(`已添加:${created.word}`);
} catch (err) {
setError(err instanceof Error ? err.message : '添加失败');
}
};
const existingWords = new Set(words.map((w) => w.word.toLowerCase()));
const quickAddable = DEFAULT_WORDS.filter((w) => !existingWords.has(w.toLowerCase()));
return (
<div className="admin-page">
<div className="admin-page-head">
<h2></h2>
<p className="muted">H5 AI </p>
</div>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
{quickAddable.length > 0 && (
<section className="admin-card">
<h2></h2>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginTop: '8px' }}>
{quickAddable.map((w) => (
<button
key={w}
type="button"
className="ghost-btn"
onClick={() => void handleQuickAdd(w)}
>
+ {w}
</button>
))}
</div>
</section>
)}
<section className="admin-card">
<h2></h2>
<form className="admin-form" onSubmit={handleAdd}>
<label className="admin-form-row">
<span></span>
<input
type="text"
placeholder="输入要屏蔽的词语"
value={newWord}
onChange={(e) => setNewWord(e.target.value)}
required
/>
</label>
<label className="admin-form-row">
<span></span>
<input
type="text"
placeholder="***"
value={newReplacement}
onChange={(e) => setNewReplacement(e.target.value)}
/>
</label>
<label className="admin-form-row">
<span></span>
<input
type="text"
placeholder="可选"
value={newNote}
onChange={(e) => setNewNote(e.target.value)}
/>
</label>
<div className="admin-actions">
<button type="submit" className="send-btn" disabled={adding || !newWord.trim()}>
{adding ? '添加中...' : '添加'}
</button>
</div>
</form>
</section>
<section className="admin-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2>{loading ? '' : `${words.length}`}</h2>
<button type="button" className="ghost-btn" onClick={() => void load()} disabled={loading}>
</button>
</div>
{loading && <p className="muted"></p>}
{!loading && words.length === 0 && <p className="muted"></p>}
{!loading && words.length > 0 && (
<table className="admin-table" style={{ marginTop: '12px' }}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{words.map((w) => (
<tr key={w.id} style={{ opacity: w.status === 'disabled' ? 0.5 : 1 }}>
{editId === w.id ? (
<>
<td>
<input
type="text"
value={editWord}
onChange={(e) => setEditWord(e.target.value)}
style={{ width: '120px' }}
/>
</td>
<td>
<input
type="text"
value={editReplacement}
onChange={(e) => setEditReplacement(e.target.value)}
style={{ width: '80px' }}
/>
</td>
<td>
<input
type="text"
value={editNote}
onChange={(e) => setEditNote(e.target.value)}
style={{ width: '120px' }}
/>
</td>
<td></td>
<td style={{ display: 'flex', gap: '6px' }}>
<button
type="button"
className="send-btn"
style={{ fontSize: '12px', padding: '2px 10px' }}
disabled={editBusy}
onClick={() => void handleSaveEdit(w.id)}
>
</button>
<button
type="button"
className="ghost-btn"
style={{ fontSize: '12px', padding: '2px 10px' }}
onClick={cancelEdit}
>
</button>
</td>
</>
) : (
<>
<td><code>{w.word}</code></td>
<td><code>{w.replacement}</code></td>
<td className="muted">{w.note || '—'}</td>
<td>
<span style={{ color: w.status === 'active' ? 'var(--color-success, #16a34a)' : 'var(--color-muted, #888)' }}>
{w.status === 'active' ? '启用' : '禁用'}
</span>
</td>
<td style={{ display: 'flex', gap: '6px' }}>
<button
type="button"
className="ghost-btn"
style={{ fontSize: '12px', padding: '2px 10px' }}
onClick={() => startEdit(w)}
>
</button>
<button
type="button"
className="ghost-btn"
style={{ fontSize: '12px', padding: '2px 10px' }}
onClick={() => void handleToggleStatus(w)}
>
{w.status === 'active' ? '禁用' : '启用'}
</button>
<button
type="button"
className="ghost-btn"
style={{ fontSize: '12px', padding: '2px 10px', color: 'var(--color-danger, #dc2626)' }}
onClick={() => void handleDelete(w.id, w.word)}
>
</button>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
);
}