diff --git a/app/products/web/admin/tokens.py b/app/products/web/admin/tokens.py
index e1c9d903c..bf5bf10f6 100644
--- a/app/products/web/admin/tokens.py
+++ b/app/products/web/admin/tokens.py
@@ -87,6 +87,11 @@ class ToggleTokenDisabledRequest(BaseModel):
disabled: bool
+class ToggleTokensDisabledRequest(BaseModel):
+ tokens: list[str]
+ disabled: bool
+
+
class TokenImportItem(BaseModel):
token: str
tags: list[str] = []
@@ -358,6 +363,69 @@ async def toggle_token_disabled(
return _json({"status": "success", "token": token, "disabled": False})
+@router.post("/tokens/disabled/batch")
+async def toggle_tokens_disabled(
+ req: ToggleTokensDisabledRequest,
+ repo: "AccountRepository" = Depends(get_repo),
+):
+ cleaned: list[str] = []
+ seen: set[str] = set()
+ for raw in req.tokens:
+ token = _sanitize(raw)
+ if token and token not in seen:
+ seen.add(token)
+ cleaned.append(token)
+ if not cleaned:
+ raise ValidationError("No valid tokens provided", param="tokens")
+
+ records = await repo.get_accounts(cleaned)
+ if not records:
+ raise AppError(
+ "No matching accounts found",
+ kind=ErrorKind.VALIDATION,
+ code="account_not_found",
+ status=404,
+ )
+
+ ts = now_ms()
+ patches: list[AccountPatch] = []
+ for record in records:
+ if req.disabled:
+ patches.append(AccountPatch(
+ token=record.token,
+ status=AccountStatus.DISABLED,
+ state_reason="operator_disabled",
+ ext_merge={
+ **record.ext,
+ "disabled_at": ts,
+ "disabled_reason": "operator_disabled",
+ },
+ ))
+ else:
+ patches.append(AccountPatch(
+ token=record.token,
+ status=AccountStatus.ACTIVE,
+ clear_failures=True,
+ ))
+
+ result = await repo.patch_accounts(patches)
+ logger.info(
+ "admin tokens disabled batch updated: disabled={} requested_count={} patched_count={}",
+ req.disabled,
+ len(cleaned),
+ result.patched,
+ )
+ return _json({
+ "status": "success",
+ "disabled": req.disabled,
+ "summary": {
+ "total": len(cleaned),
+ "ok": result.patched,
+ "fail": max(0, len(cleaned) - result.patched),
+ },
+ })
+
+
@router.put("/tokens/pool")
async def replace_pool(
req: ReplacePoolRequest,
diff --git a/app/statics/admin/account.html b/app/statics/admin/account.html
index d72e814c5..85f609a68 100644
--- a/app/statics/admin/account.html
+++ b/app/statics/admin/account.html
@@ -939,6 +939,12 @@
+
+
@@ -1119,6 +1125,8 @@
['btn-export', 'account.export', '导出数据'],
['btn-nsfw', 'account.batchNsfw', '开启 NSFW'],
['btn-refresh', 'account.batchRefresh', '刷新选中'],
+ ['btn-disable', 'account.batchDisable', '禁用选中'],
+ ['btn-restore', 'account.batchRestore', '恢复选中'],
['btn-delete', 'account.batchDelete', '删除选中'],
['btn-batch-cancel', 'account.cancel', '取消'],
];
@@ -1580,7 +1588,7 @@
}
function updateBatchBtns() {
const show = sel.size > 0;
- ['btn-nsfw','btn-refresh','btn-delete'].forEach(id =>
+ ['btn-nsfw','btn-refresh','btn-disable','btn-restore','btn-delete'].forEach(id =>
document.getElementById(id).style.display = show ? '' : 'none');
}
@@ -1811,7 +1819,7 @@
async function _runBatch(endpoint, tokens, label, onDone) {
const btnCancel = document.getElementById('btn-batch-cancel');
- ['btn-nsfw','btn-refresh','btn-delete'].forEach(id =>
+ ['btn-nsfw','btn-refresh','btn-disable','btn-restore','btn-delete'].forEach(id =>
document.getElementById(id).style.display = 'none');
btnCancel.style.display = '';
@@ -1930,6 +1938,45 @@
function disableOne(token) { setDisabled(token, true); }
function restoreOne(token) { setDisabled(token, false); }
+function batchSetDisabled(disabled) {
+ if (!sel.size) return;
+ const tokens = [...sel];
+ const n = tokens.length;
+ const title = disabled
+ ? tr('account.batchDisableConfirmTitle', null, '批量禁用账号')
+ : tr('account.batchRestoreConfirmTitle', null, '批量恢复账号');
+ const body = disabled
+ ? tr('account.batchDisableConfirmBody', { n }, `确认禁用选中的 ${n} 个账户?
禁用后这些账号不会参与请求分配,但可随时恢复。`)
+ : tr('account.batchRestoreConfirmBody', { n }, `确认恢复选中的 ${n} 个账户?
恢复后这些账号将重新参与请求分配。`);
+
+ openConfirm(title, body, async () => {
+ try {
+ showToast(
+ disabled
+ ? tr('account.disablingMany', { n }, `正在禁用 ${n} 个账户…`)
+ : tr('account.restoringMany', { n }, `正在恢复 ${n} 个账户…`),
+ 'info',
+ );
+ const d = await _api('POST', '/tokens/disabled/batch', { tokens, disabled });
+ const ok = d.summary?.ok ?? 0;
+ const fail = d.summary?.fail ?? 0;
+ showToast(
+ disabled
+ ? tr('account.disableManyDone', { ok, fail }, `禁用完成:成功 ${ok} 个,失败 ${fail} 个`)
+ : tr('account.restoreManyDone', { ok, fail }, `恢复完成:成功 ${ok} 个,失败 ${fail} 个`),
+ fail > 0 ? 'error' : 'success',
+ );
+ sel.clear();
+ await load();
+ } catch (e) {
+ showToast(`${tr('account.operationFailed', null, '操作失败')}: ${e.message}`, 'error');
+ }
+ });
+}
+
+function batchDisableSel() { batchSetDisabled(true); }
+function batchRestoreSel() { batchSetDisabled(false); }
+
async function batchRefreshSel() {
if (!sel.size) return;
await _runBatch('/batch/refresh', [...sel],
diff --git a/app/statics/i18n/de.json b/app/statics/i18n/de.json
index f81deac78..8b9c626a7 100644
--- a/app/statics/i18n/de.json
+++ b/app/statics/i18n/de.json
@@ -182,6 +182,8 @@
"batchNsfw": "NSFW aktivieren",
"batchNsfwDisable": "NSFW deaktivieren",
"batchRefresh": "Auswahl aktualisieren",
+ "batchDisable": "Auswahl deaktivieren",
+ "batchRestore": "Auswahl wiederherstellen",
"batchDelete": "Auswahl löschen",
"colToken": "Token",
"colType": "Kontotyp",
@@ -264,6 +266,14 @@
"restoreConfirmBody": "{token} wiederherstellen?
Das Konto nimmt danach wieder an der Anfrageverteilung teil.",
"restoringOne": "Konto wird wiederhergestellt…",
"restoreDone": "Konto wiederhergestellt",
+ "batchDisableConfirmTitle": "Konten deaktivieren",
+ "batchDisableConfirmBody": "Die ausgewählten {n} Konten deaktivieren?
Deaktivierte Konten werden nicht für Anfragen verwendet und können später wiederhergestellt werden.",
+ "disablingMany": "{n} Konten werden deaktiviert…",
+ "disableManyDone": "Deaktivierung abgeschlossen: {ok} erfolgreich, {fail} fehlgeschlagen",
+ "batchRestoreConfirmTitle": "Konten wiederherstellen",
+ "batchRestoreConfirmBody": "Die ausgewählten {n} Konten wiederherstellen?
Wiederhergestellte Konten werden erneut für Anfragen verwendet.",
+ "restoringMany": "{n} Konten werden wiederhergestellt…",
+ "restoreManyDone": "Wiederherstellung abgeschlossen: {ok} erfolgreich, {fail} fehlgeschlagen",
"nsfwConfirmTitle": "NSFW aktivieren",
"nsfwConfirmBody": "NSFW für {n} ausgewählte Konten aktivieren?",
"nsfwEnablingOne": "NSFW wird aktiviert…",
diff --git a/app/statics/i18n/en.json b/app/statics/i18n/en.json
index 5f7f0f13d..479a0d673 100644
--- a/app/statics/i18n/en.json
+++ b/app/statics/i18n/en.json
@@ -183,6 +183,8 @@
"batchNsfw": "Enable NSFW",
"batchNsfwDisable": "Disable NSFW",
"batchRefresh": "Refresh Selected",
+ "batchDisable": "Disable Selected",
+ "batchRestore": "Restore Selected",
"batchDelete": "Delete Selected",
"colToken": "Token",
"colType": "Account Type",
@@ -265,6 +267,14 @@
"restoreConfirmBody": "Restore {token}?
The account will be returned to request allocation.",
"restoringOne": "Restoring account…",
"restoreDone": "Account restored",
+ "batchDisableConfirmTitle": "Disable Accounts",
+ "batchDisableConfirmBody": "Disable the selected {n} accounts?
Disabled accounts are removed from request allocation and can be restored later.",
+ "disablingMany": "Disabling {n} accounts…",
+ "disableManyDone": "Disable completed: {ok} succeeded, {fail} failed",
+ "batchRestoreConfirmTitle": "Restore Accounts",
+ "batchRestoreConfirmBody": "Restore the selected {n} accounts?
Restored accounts will be returned to request allocation.",
+ "restoringMany": "Restoring {n} accounts…",
+ "restoreManyDone": "Restore completed: {ok} succeeded, {fail} failed",
"nsfwConfirmTitle": "Enable NSFW",
"nsfwConfirmBody": "Enable NSFW for {n} selected accounts?",
"nsfwEnablingOne": "Enabling NSFW…",
diff --git a/app/statics/i18n/es.json b/app/statics/i18n/es.json
index 76dfa9ac8..c94be4f85 100644
--- a/app/statics/i18n/es.json
+++ b/app/statics/i18n/es.json
@@ -182,6 +182,8 @@
"batchNsfw": "Activar NSFW",
"batchNsfwDisable": "Desactivar NSFW",
"batchRefresh": "Actualizar selección",
+ "batchDisable": "Desactivar selección",
+ "batchRestore": "Restaurar selección",
"batchDelete": "Eliminar selección",
"colToken": "Token",
"colType": "Tipo de cuenta",
@@ -264,6 +266,14 @@
"restoreConfirmBody": "¿Restaurar {token}?
La cuenta volverá a participar en la asignación de solicitudes.",
"restoringOne": "Restaurando cuenta…",
"restoreDone": "Cuenta restaurada",
+ "batchDisableConfirmTitle": "Desactivar cuentas",
+ "batchDisableConfirmBody": "¿Desactivar las {n} cuentas seleccionadas?
Las cuentas desactivadas no participarán en la asignación de solicitudes y podrán restaurarse más tarde.",
+ "disablingMany": "Desactivando {n} cuentas…",
+ "disableManyDone": "Desactivación completada: {ok} correctas, {fail} fallidas",
+ "batchRestoreConfirmTitle": "Restaurar cuentas",
+ "batchRestoreConfirmBody": "¿Restaurar las {n} cuentas seleccionadas?
Las cuentas restauradas volverán a participar en la asignación de solicitudes.",
+ "restoringMany": "Restaurando {n} cuentas…",
+ "restoreManyDone": "Restauración completada: {ok} correctas, {fail} fallidas",
"nsfwConfirmTitle": "Activar NSFW",
"nsfwConfirmBody": "¿Activar NSFW para {n} cuentas seleccionadas?",
"nsfwEnablingOne": "Activando NSFW…",
diff --git a/app/statics/i18n/fr.json b/app/statics/i18n/fr.json
index 479c98205..68d0b55b7 100644
--- a/app/statics/i18n/fr.json
+++ b/app/statics/i18n/fr.json
@@ -182,6 +182,8 @@
"batchNsfw": "Activer NSFW",
"batchNsfwDisable": "Désactiver NSFW",
"batchRefresh": "Actualiser la sélection",
+ "batchDisable": "Désactiver la sélection",
+ "batchRestore": "Restaurer la sélection",
"batchDelete": "Supprimer la sélection",
"colToken": "Token",
"colType": "Type de compte",
@@ -264,6 +266,14 @@
"restoreConfirmBody": "Restaurer {token} ?
Le compte reprendra la participation à l’allocation des requêtes.",
"restoringOne": "Restauration du compte…",
"restoreDone": "Compte restauré",
+ "batchDisableConfirmTitle": "Désactiver les comptes",
+ "batchDisableConfirmBody": "Désactiver les {n} comptes sélectionnés ?
Les comptes désactivés ne participeront plus à l’allocation des requêtes et pourront être restaurés plus tard.",
+ "disablingMany": "Désactivation de {n} comptes…",
+ "disableManyDone": "Désactivation terminée : {ok} réussies, {fail} échouées",
+ "batchRestoreConfirmTitle": "Restaurer les comptes",
+ "batchRestoreConfirmBody": "Restaurer les {n} comptes sélectionnés ?
Les comptes restaurés participeront de nouveau à l’allocation des requêtes.",
+ "restoringMany": "Restauration de {n} comptes…",
+ "restoreManyDone": "Restauration terminée : {ok} réussies, {fail} échouées",
"nsfwConfirmTitle": "Activer NSFW",
"nsfwConfirmBody": "Activer NSFW pour {n} comptes sélectionnés ?",
"nsfwEnablingOne": "Activation de NSFW…",
diff --git a/app/statics/i18n/ja.json b/app/statics/i18n/ja.json
index b43eb3586..9751a1853 100644
--- a/app/statics/i18n/ja.json
+++ b/app/statics/i18n/ja.json
@@ -182,6 +182,8 @@
"batchNsfw": "NSFW を有効化",
"batchNsfwDisable": "NSFW を無効化",
"batchRefresh": "選択項目を更新",
+ "batchDisable": "選択項目を無効化",
+ "batchRestore": "選択項目を復元",
"batchDelete": "選択項目を削除",
"colToken": "トークン",
"colType": "アカウント種別",
@@ -264,6 +266,14 @@
"restoreConfirmBody": "{token} を復元しますか?
復元後、このアカウントは再びリクエスト割り当てに参加します。",
"restoringOne": "アカウントを復元しています…",
"restoreDone": "アカウントを復元しました",
+ "batchDisableConfirmTitle": "アカウントを一括無効化",
+ "batchDisableConfirmBody": "選択した {n} 件のアカウントを無効化しますか?
無効化されたアカウントはリクエスト割り当てに参加せず、後で復元できます。",
+ "disablingMany": "{n} 件のアカウントを無効化しています…",
+ "disableManyDone": "無効化完了: 成功 {ok} 件、失敗 {fail} 件",
+ "batchRestoreConfirmTitle": "アカウントを一括復元",
+ "batchRestoreConfirmBody": "選択した {n} 件のアカウントを復元しますか?
復元後、これらのアカウントは再びリクエスト割り当てに参加します。",
+ "restoringMany": "{n} 件のアカウントを復元しています…",
+ "restoreManyDone": "復元完了: 成功 {ok} 件、失敗 {fail} 件",
"nsfwConfirmTitle": "NSFW を有効化",
"nsfwConfirmBody": "選択した {n} 件のアカウントで NSFW を有効にしますか?",
"nsfwEnablingOne": "NSFW を有効化しています…",
diff --git a/app/statics/i18n/zh.json b/app/statics/i18n/zh.json
index b72a6522b..1e53feed3 100644
--- a/app/statics/i18n/zh.json
+++ b/app/statics/i18n/zh.json
@@ -183,6 +183,8 @@
"batchNsfw": "开启 NSFW",
"batchNsfwDisable": "关闭 NSFW",
"batchRefresh": "刷新选中",
+ "batchDisable": "禁用选中",
+ "batchRestore": "恢复选中",
"batchDelete": "删除选中",
"colToken": "Token",
"colType": "账户类型",
@@ -265,6 +267,14 @@
"restoreConfirmBody": "确认恢复 {token}?
恢复后该账号将重新参与请求分配。",
"restoringOne": "正在恢复账号…",
"restoreDone": "账号已恢复",
+ "batchDisableConfirmTitle": "批量禁用账号",
+ "batchDisableConfirmBody": "确认禁用选中的 {n} 个账户?
禁用后这些账号不会参与请求分配,但可随时恢复。",
+ "disablingMany": "正在禁用 {n} 个账户…",
+ "disableManyDone": "禁用完成:成功 {ok} 个,失败 {fail} 个",
+ "batchRestoreConfirmTitle": "批量恢复账号",
+ "batchRestoreConfirmBody": "确认恢复选中的 {n} 个账户?
恢复后这些账号将重新参与请求分配。",
+ "restoringMany": "正在恢复 {n} 个账户…",
+ "restoreManyDone": "恢复完成:成功 {ok} 个,失败 {fail} 个",
"nsfwConfirmTitle": "启用 NSFW",
"nsfwConfirmBody": "确认为选中的 {n} 个账户启用 NSFW?",
"nsfwEnablingOne": "正在启用 NSFW…",