功能:禁用词管理支持批量添加

- 新增 ForbiddenUsernameController::batchStore()
  支持换行、逗号、中文逗号、空格多种分隔格式
  自动去重、跳过已存在词语、忽略超长词
  返回成功数/跳过数详细提示
- 新增路由 POST /admin/forbidden-usernames/batch
- View 新增卡片加「单个/批量」两 Tab 切换
  批量 Tab 使用 textarea 多行输入
This commit is contained in:
2026-03-01 14:04:28 +08:00
parent fc495ccceb
commit 632a4240c4
3 changed files with 133 additions and 8 deletions
@@ -80,6 +80,72 @@ class ForbiddenUsernameController extends Controller
return response()->json(['status' => 'success', 'message' => "{$username}」已加入永久禁用列表。"]);
}
/**
* 批量添加永久禁用词。
*
* 接受多行文本或逗号分隔的词语列表,自动去重并过滤已存在者。
* 返回成功添加数量和跳过数量。
*
* @param Request $request 请求体:words(换行/逗号分隔),reason(选填,共用)
*/
public function batchStore(Request $request): JsonResponse
{
$validated = $request->validate([
'words' => ['required', 'string'],
'reason' => ['nullable', 'string', 'max:100'],
], [
'words.required' => '请输入至少一个词语。',
]);
// 支持换行、逗号、中文逗号、空格分隔
$rawWords = preg_split('/[\r\n,\s]+/', $validated['words']);
$reason = trim($validated['reason'] ?? '');
// 过滤空串、截断过长、去重
$words = collect($rawWords)
->map(fn ($w) => trim($w))
->filter(fn ($w) => $w !== '' && mb_strlen($w) <= 50)
->unique()
->values();
if ($words->isEmpty()) {
return response()->json(['status' => 'error', 'message' => '没有有效的词语,请检查输入。'], 422);
}
// 查询已存在的(批量一次查询)
$existing = UsernameBlacklist::permanent()
->whereIn('username', $words->all())
->pluck('username')
->flip(); // 转为键名方便 has() 查询
$now = Carbon::now();
$added = 0;
$rows = [];
foreach ($words as $word) {
if ($existing->has($word)) {
continue; // 跳过已存在
}
$rows[] = [
'username' => $word,
'type' => 'permanent',
'reserved_until' => null,
'reason' => $reason ?: null,
'created_at' => $now,
];
$added++;
}
if (! empty($rows)) {
UsernameBlacklist::insert($rows);
}
$skipped = $words->count() - $added;
$msg = "成功添加 {$added} 个词语".($skipped > 0 ? ",跳过 {$skipped} 个(已存在)" : '').'。';
return response()->json(['status' => 'success', 'message' => $msg, 'added' => $added]);
}
/**
* 更新指定禁用词的原因备注。
*
@@ -25,12 +25,14 @@
{{-- 新增表单 + 搜索栏 --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6" x-data="{
tab: 'single',
username: '',
words: '',
reason: '',
saving: false,
msg: '',
msgOk: true,
async addWord() {
async addSingle() {
if (!this.username.trim()) return;
this.saving = true;
this.msg = '';
@@ -50,28 +52,84 @@
if (this.msgOk) { this.username = '';
this.reason = '';
setTimeout(() => location.reload(), 800); }
},
async addBatch() {
if (!this.words.trim()) return;
this.saving = true;
this.msg = '';
const res = await fetch('{{ route('admin.forbidden-usernames.batch') }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ words: this.words, reason: this.reason }),
});
const data = await res.json();
this.saving = false;
this.msgOk = data.status === 'success';
this.msg = data.message;
if (this.msgOk) { this.words = '';
this.reason = '';
setTimeout(() => location.reload(), 1200); }
}
}">
{{-- 新增卡片 --}}
{{-- 新增卡片(单个 + 批量 Tab --}}
<div class="bg-white rounded-xl border border-gray-200 shadow-sm p-5">
<h3 class="font-bold text-gray-700 text-sm mb-3"> 新增禁用词</h3>
{{-- Tab 切换 --}}
<div class="flex border-b border-gray-200 mb-4 -mx-5 px-5 gap-1">
<button @click="tab='single'; msg=''"
:class="tab === 'single' ? 'border-b-2 border-indigo-600 text-indigo-700 font-bold' :
'text-gray-500 hover:text-gray-700'"
class="pb-2 text-sm px-1 transition"> 单个添加</button>
<button @click="tab='batch'; msg=''"
:class="tab === 'batch' ? 'border-b-2 border-indigo-600 text-indigo-700 font-bold' :
'text-gray-500 hover:text-gray-700'"
class="pb-2 text-sm px-1 transition">📋 批量添加</button>
</div>
<div class="space-y-3">
<div>
<label class="text-xs text-gray-500 mb-1 block">禁用词(用户名)<span class="text-red-500">*</span></label>
{{-- 单个模式 --}}
<div x-show="tab==='single'">
<label class="text-xs text-gray-500 mb-1 block">禁用词 <span class="text-red-500">*</span></label>
<input x-model="username" type="text" maxlength="50" placeholder="如:admin、习近平、fuck…"
@keydown.enter="addWord()"
@keydown.enter="addSingle()"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-400 outline-none">
</div>
{{-- 批量模式 --}}
<div x-show="tab==='batch'">
<label class="text-xs text-gray-500 mb-1 block">
批量词语 <span class="text-red-500">*</span>
<span class="text-gray-400 font-normal">(每行一个,或用逗号/空格分隔)</span>
</label>
<textarea x-model="words" rows="6" maxlength="5000"
placeholder="admin&#10;root&#10;fuck&#10;操,草,傻&#10;…(每行一个或逗号分隔)"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-400 outline-none resize-y font-mono"></textarea>
<p class="text-xs text-gray-400 mt-1">
自动去重,跳过已存在的词语,超过50字符的词语忽略
</p>
</div>
{{-- 共用原因 --}}
<div>
<label class="text-xs text-gray-500 mb-1 block">禁用原因(备注,选填)</label>
<label class="text-xs text-gray-500 mb-1 block">禁用原因(选填)</label>
<input x-model="reason" type="text" maxlength="100" placeholder="如:国家领导人姓名 / 攻击性词汇"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-400 outline-none">
</div>
<button @click="addWord()" :disabled="saving || !username.trim()"
{{-- 提交按钮 --}}
<button x-show="tab==='single'" @click="addSingle()" :disabled="saving || !username.trim()"
class="w-full bg-indigo-600 hover:bg-indigo-700 disabled:opacity-40 text-white rounded-lg px-4 py-2 text-sm font-bold transition">
<span x-text="saving ? '添加中…' : ' 添加到禁用列表'"></span>
</button>
<button x-show="tab==='batch'" @click="addBatch()" :disabled="saving || !words.trim()"
class="w-full bg-indigo-600 hover:bg-indigo-700 disabled:opacity-40 text-white rounded-lg px-4 py-2 text-sm font-bold transition">
<span x-text="saving ? '处理中…' : '📋 批量添加到禁用列表'"></span>
</button>
<p x-show="msg" x-text="msg" :class="msgOk ? 'text-green-600' : 'text-red-500'"
class="text-xs font-bold"></p>
</div>
+1
View File
@@ -265,6 +265,7 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad
// 禁用用户名管理(永久禁止注册/改名的词语:领导人名称、攻击性词汇等)
Route::get('/forbidden-usernames', [\App\Http\Controllers\Admin\ForbiddenUsernameController::class, 'index'])->name('forbidden-usernames.index');
Route::post('/forbidden-usernames', [\App\Http\Controllers\Admin\ForbiddenUsernameController::class, 'store'])->name('forbidden-usernames.store');
Route::post('/forbidden-usernames/batch', [\App\Http\Controllers\Admin\ForbiddenUsernameController::class, 'batchStore'])->name('forbidden-usernames.batch');
Route::put('/forbidden-usernames/{id}', [\App\Http\Controllers\Admin\ForbiddenUsernameController::class, 'update'])->name('forbidden-usernames.update');
Route::delete('/forbidden-usernames/{id}', [\App\Http\Controllers\Admin\ForbiddenUsernameController::class, 'destroy'])->name('forbidden-usernames.destroy');
});