fix(subscription): prevent duplicate qb downloads

This commit is contained in:
ShukeBta
2026-06-07 10:05:49 +08:00
parent 22b64d3d47
commit d94330b30f
22 changed files with 990 additions and 75 deletions
+3
View File
@@ -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),
+46 -1
View File
@@ -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}
+216
View File
@@ -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">设备限制 &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 (
<label className="block">