mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 12:26:36 +08:00
fix(subscription): prevent duplicate qb downloads
This commit is contained in:
@@ -13,6 +13,9 @@ export const adminAPI = {
|
||||
resetUserPassword: (id: string, password: string) =>
|
||||
api.patch(`/admin/users/${id}/password`, { password }).then((r) => r.data),
|
||||
|
||||
setUserStatus: (id: string, isActive: boolean) =>
|
||||
api.patch<User>(`/admin/users/${id}/status`, { is_active: isActive }).then((r) => r.data),
|
||||
|
||||
deleteUser: (id: string) => api.delete(`/admin/users/${id}`).then((r) => r.data),
|
||||
|
||||
listSettings: () => api.get<Setting[]>('/admin/settings').then((r) => r.data),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { KeyRound, Pencil, Plus, ShieldCheck, Trash2, X } from 'lucide-react'
|
||||
import { KeyRound, Pencil, Plus, ShieldCheck, Trash2, UserCheck, UserX, X } from 'lucide-react'
|
||||
|
||||
import { adminAPI } from '../api/admin'
|
||||
import { libraryAPI } from '../api/library'
|
||||
@@ -245,6 +245,34 @@ function UsersPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleStatus = async (u: User) => {
|
||||
const next = !u.is_active
|
||||
if (!next && u.is_protected) {
|
||||
toast.error('受保护管理员不可禁用')
|
||||
return
|
||||
}
|
||||
if (
|
||||
!next &&
|
||||
!(await confirmAction({
|
||||
title: '禁用用户',
|
||||
message: `禁用「${u.username}」后,Web 与第三方客户端已有登录也会失效。`,
|
||||
confirmText: '禁用',
|
||||
}))
|
||||
) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await adminAPI.setUserStatus(u.id, next)
|
||||
toast.success(next ? '用户已解禁' : '用户已禁用')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'操作失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={handleCreate} className="glass-panel grid gap-3 md:grid-cols-[1fr_1fr_auto]">
|
||||
@@ -289,6 +317,7 @@ function UsersPanel() {
|
||||
<tr>
|
||||
<th className="py-2">用户名</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>权限说明</th>
|
||||
<th>最近登录</th>
|
||||
<th className="text-right">操作</th>
|
||||
@@ -312,6 +341,9 @@ function UsersPanel() {
|
||||
)}
|
||||
</td>
|
||||
<td className="text-ink-100">{u.role === 'admin' ? '管理员' : '观看用户'}</td>
|
||||
<td className={u.is_active ? 'text-green-500' : 'text-red-400'}>
|
||||
{u.is_active ? '正常' : '已禁用'}
|
||||
</td>
|
||||
<td className="text-ink-50">
|
||||
{u.role === 'admin' ? '全部管理权限' : '仅浏览/播放/外部播放器,无下载与文件操作'}
|
||||
</td>
|
||||
@@ -349,6 +381,19 @@ function UsersPanel() {
|
||||
>
|
||||
<KeyRound size={12} />
|
||||
</button>
|
||||
<button
|
||||
className={
|
||||
'rounded-lg border px-2 py-1 text-xs disabled:cursor-not-allowed disabled:opacity-40 ' +
|
||||
(u.is_active
|
||||
? 'border-orange-400/40 text-orange-500 hover:bg-orange-400/10'
|
||||
: 'border-green-400/40 text-green-500 hover:bg-green-400/10')
|
||||
}
|
||||
disabled={u.is_protected && u.is_active}
|
||||
title={u.is_active ? '禁用用户' : '解禁用户'}
|
||||
onClick={() => toggleStatus(u)}
|
||||
>
|
||||
{u.is_active ? <UserX size={12} /> : <UserCheck size={12} />}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={u.is_protected}
|
||||
|
||||
@@ -43,6 +43,7 @@ export function ToolsPage() {
|
||||
/>
|
||||
|
||||
<OrganizePanel />
|
||||
<AccountCleanupPanel />
|
||||
<NotifyPanel />
|
||||
</div>
|
||||
)
|
||||
@@ -559,6 +560,221 @@ 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 (
|
||||
<label className="block">
|
||||
|
||||
Reference in New Issue
Block a user