319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|