feat(bot): manage device policy via telegram commands

This commit is contained in:
ShukeBta
2026-06-07 12:24:21 +08:00
parent cf2f77f35d
commit b38d47a27d
11 changed files with 667 additions and 237 deletions
-14
View File
@@ -267,20 +267,6 @@ const GROUPS: SettingGroup[] = [
},
],
},
{
key: 'telegram',
label: 'Telegram Bot',
description: '机器人注册与普通用户权限',
items: [
{
key: 'telegram.registration_enabled',
label: '允许普通用户通过 Bot 注册',
type: 'toggle',
hint: '默认关闭。开启后用户可在 Telegram 中用 /register 用户名 密码 注册账号并自动绑定;关闭后注册被拒绝。普通用户始终只能绑定账号、开关成人目录显隐及使用 /start、/help。',
defaultValue: 'false',
},
],
},
// qBittorrent 配置已迁移到独立的「下载器」页面(侧边栏 → 下载器),
// 该页面支持多客户端 + 连接测试。这里不再重复暴露入口,避免与
// /api/admin/download/clients 写入的数据来源冲突。
+1 -216
View File
@@ -43,7 +43,6 @@ export function ToolsPage() {
/>
<OrganizePanel />
<AccountCleanupPanel />
<NotifyPanel />
</div>
)
@@ -145,7 +144,7 @@ function OrganizePanel() {
const sc = defaults['organizer.smart_classify']
setSmartClassify(sc === 'true' || sc === '1' || sc === 'on')
toast.success('整理默认设置已保存')
} catch (err) {
} catch {
toast.error('保存失败')
} finally {
setSavingDefaults(false)
@@ -560,220 +559,6 @@ function NotifyPanel() {
)
}
type CleanupRule = {
id: string
name: string
type: 'watch_hours' | 'recent_login' | 'signin_streak' | 'account_age_grace'
enabled: boolean
window_days_min?: number
window_days_max?: number
min_hours?: number
min_count?: number
}
const cleanupKeys = {
enabled: 'device.account_cleanup_enabled',
mode: 'device.account_cleanup_keep_mode',
required: 'device.account_cleanup_required_count',
rules: 'device.account_cleanup_rules',
antishare: 'device.antishare_enabled',
maxPlay: 'device.max_concurrent_play',
maxClients: 'device.max_logged_clients',
warnThreshold: 'device.warn_threshold',
}
function defaultCleanupRules(): CleanupRule[] {
return [
{
id: 'watch_3_5d_6h',
name: '3~5 天观看满 6 小时',
type: 'watch_hours',
enabled: true,
window_days_min: 3,
window_days_max: 5,
min_hours: 6,
},
]
}
function AccountCleanupPanel() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [enabled, setEnabled] = useState(false)
const [antiShare, setAntiShare] = useState(false)
const [maxPlay, setMaxPlay] = useState(3)
const [maxClients, setMaxClients] = useState(3)
const [warnThreshold, setWarnThreshold] = useState(2)
const [mode, setMode] = useState<'any' | 'all' | 'count'>('any')
const [required, setRequired] = useState(1)
const [rules, setRules] = useState<CleanupRule[]>(defaultCleanupRules())
useEffect(() => {
adminAPI
.listSettings()
.then((settings) => {
const byKey: Record<string, string> = {}
for (const s of settings) byKey[s.key] = s.value
setEnabled(isOn(byKey[cleanupKeys.enabled]))
setAntiShare(isOn(byKey[cleanupKeys.antishare]))
setMode((['any', 'all', 'count'].includes(byKey[cleanupKeys.mode]) ? byKey[cleanupKeys.mode] : 'any') as 'any' | 'all' | 'count')
setRequired(Number(byKey[cleanupKeys.required] || 1))
setMaxPlay(Number(byKey[cleanupKeys.maxPlay] || 3))
setMaxClients(Number(byKey[cleanupKeys.maxClients] || 3))
setWarnThreshold(Number(byKey[cleanupKeys.warnThreshold] || 2))
if (byKey[cleanupKeys.rules]) {
try {
const parsed = JSON.parse(byKey[cleanupKeys.rules]) as CleanupRule[]
if (Array.isArray(parsed) && parsed.length > 0) setRules(parsed)
} catch {
// keep defaults
}
}
})
.finally(() => setLoading(false))
}, [])
const updateRule = (id: string, patch: Partial<CleanupRule>) =>
setRules((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)))
const addRule = () =>
setRules((prev) => [
...prev,
{
id: `rule_${Date.now()}`,
name: '新保号规则',
type: 'watch_hours',
enabled: true,
window_days_min: 3,
window_days_max: 5,
min_hours: 6,
},
])
const save = async () => {
setSaving(true)
try {
await adminAPI.updateSetting(cleanupKeys.antishare, antiShare ? 'true' : 'false')
await adminAPI.updateSetting(cleanupKeys.maxPlay, String(Math.max(1, maxPlay)))
await adminAPI.updateSetting(cleanupKeys.maxClients, String(Math.max(1, maxClients)))
await adminAPI.updateSetting(cleanupKeys.warnThreshold, String(Math.max(1, warnThreshold)))
await adminAPI.updateSetting(cleanupKeys.enabled, enabled ? 'true' : 'false')
await adminAPI.updateSetting(cleanupKeys.mode, mode)
await adminAPI.updateSetting(cleanupKeys.required, String(Math.max(1, required)))
await adminAPI.updateSetting(cleanupKeys.rules, JSON.stringify(rules))
toast.success('设备策略与删号规则已保存')
} catch {
toast.error('保存失败')
} finally {
setSaving(false)
}
}
return (
<section className="glass-panel space-y-4">
<div className="flex items-center gap-2">
<Info size={18} className="text-red-400" />
<h2 className="font-display text-lg font-semibold text-ink-600">设备限制 &amp; 删号规则</h2>
</div>
<p className="text-xs text-sand-500">
设备限制用于防共享:同一用户同时播放超过上限会被禁用,管理员可在用户管理或 Bot 中解禁。删号规则为“保号规则”:用户满足足够条件则保留,否则巡检时删除。
</p>
{loading ? (
<div className="text-sm text-ink-50">读取配置中…</div>
) : (
<>
<div className="grid gap-3 md:grid-cols-4">
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm text-ink-600">
<input type="checkbox" checked={antiShare} onChange={(e) => setAntiShare(e.target.checked)} />
开启设备限制
</label>
<NumberField label="同时播放上限" value={maxPlay} onChange={setMaxPlay} />
<NumberField label="登录设备上限" value={maxClients} onChange={setMaxClients} />
<NumberField label="指纹警告阈值" value={warnThreshold} onChange={setWarnThreshold} />
</div>
<div className="grid gap-3 rounded-xl border border-gray-200 bg-white/40 p-3 md:grid-cols-4">
<label className="flex items-center gap-2 text-sm text-ink-600">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
开启删号规则巡检
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">保号模式</span>
<select className="input-base w-full" value={mode} onChange={(e) => setMode(e.target.value as 'any' | 'all' | 'count')}>
<option value="any">满足任意一条规则</option>
<option value="all">满足全部规则</option>
<option value="count">满足指定数量规则</option>
</select>
</label>
<NumberField label="指定数量" value={required} onChange={setRequired} disabled={mode !== 'count'} />
<button type="button" className="neon-button self-end" onClick={addRule}>
添加规则
</button>
</div>
<div className="space-y-3">
{rules.map((rule) => (
<div key={rule.id} className="grid gap-2 rounded-xl border border-gray-200 bg-gray-50 p-3 md:grid-cols-6">
<label className="flex items-center gap-2 text-xs text-ink-600">
<input type="checkbox" checked={rule.enabled} onChange={(e) => updateRule(rule.id, { enabled: e.target.checked })} />
启用
</label>
<input className="input-base" value={rule.name} onChange={(e) => updateRule(rule.id, { name: e.target.value })} />
<select className="input-base" value={rule.type} onChange={(e) => updateRule(rule.id, { type: e.target.value as CleanupRule['type'] })}>
<option value="watch_hours">观看时长</option>
<option value="recent_login">最近登录</option>
<option value="signin_streak">连续签到</option>
<option value="account_age_grace">新号宽限</option>
</select>
<NumberField label="窗口下限/天" value={rule.window_days_min ?? 3} onChange={(v) => updateRule(rule.id, { window_days_min: v })} />
<NumberField label="窗口上限/天" value={rule.window_days_max ?? 5} onChange={(v) => updateRule(rule.id, { window_days_max: v })} />
<div className="flex gap-2">
{rule.type === 'watch_hours' ? (
<NumberField label="最低小时" value={rule.min_hours ?? 6} onChange={(v) => updateRule(rule.id, { min_hours: v })} />
) : (
<NumberField label="最低数量/天" value={rule.min_count ?? 1} onChange={(v) => updateRule(rule.id, { min_count: v })} />
)}
<button type="button" className="rounded-lg border border-red-400/40 px-2 text-xs text-red-400" onClick={() => setRules((prev) => prev.filter((r) => r.id !== rule.id))}>
删除
</button>
</div>
</div>
))}
</div>
<button type="button" disabled={saving} className="neon-button" onClick={save}>
{saving ? <Loader2 size={16} className="animate-spin" /> : null}
保存设备与删号规则
</button>
</>
)}
</section>
)
}
function NumberField({
label,
value,
onChange,
disabled,
}: {
label: string
value: number
onChange: (value: number) => void
disabled?: boolean
}) {
return (
<label className="space-y-1">
<span className="text-xs text-ink-50">{label}</span>
<input
type="number"
min={0}
step={label.includes('小时') ? 0.5 : 1}
disabled={disabled}
className="input-base w-full"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
</label>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (