mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 19:36:36 +08:00
feat(bot): manage device policy via telegram commands
This commit is contained in:
@@ -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
@@ -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">设备限制 & 删号规则</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 (
|
||||
|
||||
Reference in New Issue
Block a user