😋 增加了违禁词管理功能

This commit is contained in:
LeonspaceX
2025-10-20 21:15:28 +08:00
parent d63ecd5a33
commit 03f944c992
6 changed files with 248 additions and 7 deletions

View File

@@ -103,6 +103,22 @@ location / {
后端API已部署完成喵接下来请调用/init接口进行初始化 后端API已部署完成喵接下来请调用/init接口进行初始化
## TODO
目前收到的几个建议:
1、开设不同讨论板块
## License ## License

View File

@@ -1,4 +1,8 @@
# Hi~欢迎来到Sycamore_Whisper匿名投稿站 # Hi~欢迎来到Sycamore_Whisper匿名投稿站
这是一个实例关于页面。
这里的内容来自开发环境下的/public/about.md请编辑此文件以便在这里显示自己的内容 这里的内容来自开发环境下的/public/about.md请编辑此文件以便在这里显示自己的内容
如果你不了解Markdown文档的语法可以前往[这里](https://www.runoob.com/markdown/md-tutorial.html)简单学习 如果你不了解Markdown文档的语法可以前往[这里](https://www.runoob.com/markdown/md-tutorial.html)简单学习

View File

@@ -113,8 +113,8 @@ function App() {
<Route path="about" element={<AboutPage />} /> <Route path="about" element={<AboutPage />} />
</Route> </Route>
<Route path="/init" element={<InitPage />} /> <Route path="/init" element={<InitPage />} />
<Route path="/admin" element={<AdminPage />} /> <Route path="/admin" element={<AdminPage isDarkMode={isDarkMode} onToggleTheme={() => setIsDarkMode(!isDarkMode)} />} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
<ToastContainer /> <ToastContainer />

View File

@@ -730,3 +730,43 @@ export const modifyComment = async (
} }
return resp.json(); return resp.json();
}; };
/**
* 获取违禁词列表
* GET /admin/get/banned_keywords -> { keywords: string[] }
*/
export const getBannedKeywords = async (): Promise<string[]> => {
const resp = await adminApiRequest('/get/banned_keywords', { method: 'GET' });
if (!resp.ok) {
throw new Error(`获取违禁词失败: ${resp.status}`);
}
const data = await resp.json();
const list = Array.isArray(data?.keywords) ? data.keywords : (Array.isArray(data) ? data : []);
return list.map((x: any) => String(x)).filter(Boolean);
};
/**
* 保存违禁词列表
* POST /admin/banned_keywords { BANNED_KEYWORDS: string[] }
*/
export const setBannedKeywordsList = async (keywords: string[]): Promise<{ status: 'OK' }> => {
const clean = (keywords || []).map((x) => String(x).trim()).filter((x) => !!x);
const resp = await adminApiRequest('/banned_keywords', {
method: 'POST',
body: JSON.stringify({ BANNED_KEYWORDS: clean }),
});
if (!resp.ok) {
let detail = '';
try {
const ct = resp.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
const d = await resp.json();
detail = typeof d === 'string' ? d : (d?.reason || JSON.stringify(d));
} else {
detail = await resp.text();
}
} catch {}
throw new Error(`保存违禁词失败: ${resp.status}${detail ? ` - ${detail}` : ''}`);
}
return resp.json();
};

View File

@@ -12,15 +12,23 @@ import {
DialogTitle, DialogTitle,
DialogContent, DialogContent,
DialogActions, DialogActions,
Input,
Tooltip,
shorthands,
} from '@fluentui/react-components'; } from '@fluentui/react-components';
import type { TabValue } from '@fluentui/react-components'; import type { TabValue } from '@fluentui/react-components';
import { getAuditMode, setAuditMode, getBackupZip, recoverBackup, getPicLinks, deletePic, type PicLink, getPendingReports, approveReport, rejectReport, type PendingReport, getAdminPostInfo, getPendingPosts, getRejectedPosts, type AdminPostListItem, approvePost, disapprovePost, reauditPost, deletePost } from '../admin_api'; import { getAuditMode, setAuditMode, getBackupZip, recoverBackup, getPicLinks, deletePic, type PicLink, getPendingReports, approveReport, rejectReport, type PendingReport, getAdminPostInfo, getPendingPosts, getRejectedPosts, type AdminPostListItem, approvePost, disapprovePost, reauditPost, deletePost, modifyPost,
getBannedKeywords, setBannedKeywordsList } from '../admin_api';
import { Switch } from '@fluentui/react-components'; import { Switch } from '@fluentui/react-components';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { import {
SignOut24Regular, SignOut24Regular,
WeatherSunny24Regular, WeatherSunny24Regular,
WeatherMoon24Regular WeatherMoon24Regular,
Dismiss12Regular,
Add20Regular,
Save20Regular,
QuestionCircle20Regular,
} from '@fluentui/react-icons'; } from '@fluentui/react-icons';
import { adminLogout } from '../admin_api'; import { adminLogout } from '../admin_api';
import { SITE_TITLE } from '../config'; import { SITE_TITLE } from '../config';
@@ -105,6 +113,40 @@ const useStyles = makeStyles({
alignItems: 'center', alignItems: 'center',
zIndex: 999, zIndex: 999,
}, },
bannedRow: {
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: tokens.spacingHorizontalS,
...shorthands.padding(tokens.spacingVerticalS, 0),
},
chip: {
display: 'inline-flex',
alignItems: 'center',
gap: tokens.spacingHorizontalXS,
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.border('1px', 'solid', tokens.colorNeutralStroke1),
...shorthands.borderRadius(tokens.borderRadiusLarge),
...shorthands.padding(tokens.spacingVerticalXS, tokens.spacingHorizontalS),
},
chipText: {
fontSize: tokens.fontSizeBase300,
lineHeight: '20px',
},
chipDismiss: {
cursor: 'pointer',
color: tokens.colorNeutralForeground3,
},
addInput: {
width: '220px',
},
dashedAdd: {
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.border('1px', 'dashed', tokens.colorNeutralStroke1),
},
fileInputHidden: {
display: 'none',
},
}); });
interface AdminDashboardProps { interface AdminDashboardProps {
@@ -163,6 +205,14 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
// 评论管理弹窗 // 评论管理弹窗
const [manageCommentsModal, setManageCommentsModal] = React.useState<{ open: boolean; id?: number }>({ open: false }); const [manageCommentsModal, setManageCommentsModal] = React.useState<{ open: boolean; id?: number }>({ open: false });
// 违禁词状态
const [bannedKeywords, setBannedKeywords] = React.useState<string[]>([]);
const [newKeyword, setNewKeyword] = React.useState<string>('');
const [bannedLoading, setBannedLoading] = React.useState<boolean>(false);
const [bannedSaving, setBannedSaving] = React.useState<boolean>(false);
const fileImportRef = React.useRef<HTMLInputElement | null>(null);
const [importing, setImporting] = React.useState<boolean>(false);
React.useEffect(() => { React.useEffect(() => {
if (activeTab === 'systemSettings') { if (activeTab === 'systemSettings') {
setLoadingAudit(true); setLoadingAudit(true);
@@ -172,7 +222,7 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
}) })
.catch((err: any) => { .catch((err: any) => {
console.error(err); console.error(err);
const msg = String(err?.message || ''); const msg = String(err?.message || '获取审核模式失败');
if (msg.includes('401') || msg.includes('403') || msg.includes('登录已过期')) { if (msg.includes('401') || msg.includes('403') || msg.includes('登录已过期')) {
toast.error('身份验证失败,请重新登陆'); toast.error('身份验证失败,请重新登陆');
} else { } else {
@@ -180,6 +230,21 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
} }
}) })
.finally(() => setLoadingAudit(false)); .finally(() => setLoadingAudit(false));
// 加载违禁词
setBannedLoading(true);
getBannedKeywords()
.then((list) => setBannedKeywords(Array.isArray(list) ? list : []))
.catch((err: any) => {
console.error(err);
const msg = String(err?.message || '获取违禁词失败');
if (msg.includes('401') || msg.includes('403') || msg.includes('登录已过期')) {
toast.error('身份验证失败,请重新登陆');
} else {
toast.error('获取违禁词失败');
}
})
.finally(() => setBannedLoading(false));
} else if (activeTab === 'imageManage') { } else if (activeTab === 'imageManage') {
setPicLoading(true); setPicLoading(true);
getPicLinks(picPage) getPicLinks(picPage)
@@ -353,6 +418,62 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
} }
}; };
// 违禁词操作
const handleAddKeyword = () => {
const raw = newKeyword.trim();
if (!raw) return;
const parts = raw.split(/[,\s]+/).map(x => x.trim()).filter(Boolean);
const set = new Set([...bannedKeywords.map(x => x.trim()), ...parts]);
setBannedKeywords([...set]);
setNewKeyword('');
};
const handleRemoveKeyword = (word: string) => {
setBannedKeywords(prev => prev.filter(x => x !== word));
};
const handleSaveKeywords = async () => {
try {
setBannedSaving(true);
await setBannedKeywordsList(bannedKeywords);
toast.success('已保存违禁词列表');
} catch (e: any) {
const msg = String(e?.message || '保存失败');
if (msg.includes('401') || msg.includes('403') || msg.includes('登录已过期')) {
toast.error('身份验证失败,请重新登陆');
} else {
toast.error(msg);
}
} finally {
setBannedSaving(false);
}
};
const handleClickImportFile = () => {
fileImportRef.current?.click();
};
const handleImportFromText = async (file: File) => {
try {
setImporting(true);
const text = await file.text();
const parts = text
.split(/[\n\r\t,;\s]+/)
.map(s => s.trim())
.filter(Boolean);
const set = new Set([...bannedKeywords.map(x => x.trim()), ...parts]);
const added = Math.max(0, [...set].length - bannedKeywords.length);
setBannedKeywords([...set]);
toast.success(`已导入 ${added} 个违禁词`);
} catch (e: any) {
const msg = String(e?.message || '读取文件失败');
toast.error(msg.includes('Failed') ? '读取文件失败请确认为TXT文本' : msg);
} finally {
setImporting(false);
if (fileImportRef.current) fileImportRef.current.value = '';
}
};
const handleCreateBackup = async () => { const handleCreateBackup = async () => {
try { try {
const { blob, filename } = await getBackupZip(); const { blob, filename } = await getBackupZip();
@@ -551,6 +672,8 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
{activeTab === 'systemSettings' ? ( {activeTab === 'systemSettings' ? (
<div> <div>
<Text size={400} weight="semibold"></Text> <Text size={400} weight="semibold"></Text>
{/* 审核开关 */}
<div style={{ marginTop: tokens.spacingVerticalM }}> <div style={{ marginTop: tokens.spacingVerticalM }}>
<Text size={300}></Text> <Text size={300}></Text>
<div style={{ marginTop: tokens.spacingVerticalS }}> <div style={{ marginTop: tokens.spacingVerticalS }}>
@@ -565,6 +688,59 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
</div> </div>
</div> </div>
{/* 违禁词设置 */}
<div style={{ marginTop: tokens.spacingVerticalL }}>
<Text size={300}></Text>
<div className={styles.bannedRow}>
{bannedKeywords.map((word) => (
<span key={word} className={styles.chip}>
<span className={styles.chipText}>{word}</span>
<span className={styles.chipDismiss} role="button" aria-label={`删除 ${word}`} onClick={() => handleRemoveKeyword(word)}>
<Dismiss12Regular />
</span>
</span>
))}
<Input
placeholder="输入新违禁词,支持逗号分隔"
value={newKeyword}
onChange={(e) => setNewKeyword((e.target as HTMLInputElement).value)}
className={styles.addInput}
/>
<Button appearance="outline" icon={<Add20Regular />} className={styles.dashedAdd} onClick={handleAddKeyword} disabled={bannedLoading}>
</Button>
<input
ref={fileImportRef}
type="file"
accept=".txt,text/plain"
className={styles.fileInputHidden}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleImportFromText(f);
}}
disabled={bannedLoading || importing}
/>
<Button appearance="outline" icon={<Add20Regular />} className={styles.dashedAdd} onClick={handleClickImportFile} disabled={bannedLoading || importing}>
</Button>
<Button appearance="primary" icon={<Save20Regular />} onClick={handleSaveKeywords} disabled={bannedSaving || bannedLoading}>
</Button>
<Tooltip
relationship="description"
content={(
<>
,;<br />
, nm <br />
</>
)}
>
<Button appearance="subtle" icon={<QuestionCircle20Regular />} aria-label="分隔符说明" />
</Tooltip>
</div>
</div>
{/* 备份 */} {/* 备份 */}
<div style={{ marginTop: tokens.spacingVerticalL }}> <div style={{ marginTop: tokens.spacingVerticalL }}>
<Text size={300}></Text> <Text size={300}></Text>

View File

@@ -4,7 +4,12 @@ import AdminLogin from './AdminLogin';
import AdminDashboard from './AdminDashboard'; import AdminDashboard from './AdminDashboard';
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
const AdminPage: React.FC = () => { interface AdminPageProps {
isDarkMode: boolean;
onToggleTheme: () => void;
}
const AdminPage: React.FC<AdminPageProps> = ({ isDarkMode, onToggleTheme }) => {
const [isLoggedIn, setIsLoggedIn] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -34,7 +39,7 @@ const AdminPage: React.FC = () => {
return ( return (
<> <>
{isLoggedIn ? ( {isLoggedIn ? (
<AdminDashboard onLogout={handleLogout} /> <AdminDashboard onLogout={handleLogout} isDarkMode={isDarkMode} onToggleTheme={onToggleTheme} />
) : ( ) : (
<AdminLogin onLoginSuccess={handleLoginSuccess} /> <AdminLogin onLoginSuccess={handleLoginSuccess} />
)} )}