mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-07 01:56:53 +00:00
Add a Bun script to apply and normalize AGPL copyright headers across the default frontend source files. The script keeps headers idempotent, upgrades existing headers to the 2023-2026 QuantumNous range, and is exposed through `bun run copyright` for future maintenance.
375 lines
12 KiB
TypeScript
Vendored
375 lines
12 KiB
TypeScript
Vendored
/*
|
|
Copyright (C) 2023-2026 QuantumNous
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as
|
|
published by the Free Software Foundation, either version 3 of the
|
|
License, or (at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
For commercial licensing, please contact support@quantumnous.com
|
|
*/
|
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { Plus } from 'lucide-react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { toast } from 'sonner'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
SheetDescription,
|
|
} from '@/components/ui/sheet'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
|
import { StatusBadge } from '@/components/status-badge'
|
|
import {
|
|
getAdminPlans,
|
|
getUserSubscriptions,
|
|
createUserSubscription,
|
|
invalidateUserSubscription,
|
|
deleteUserSubscription,
|
|
} from '../../api'
|
|
import { formatTimestamp } from '../../lib'
|
|
import type { PlanRecord, UserSubscriptionRecord } from '../../types'
|
|
|
|
interface Props {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
user: { id: number; username?: string } | null
|
|
onSuccess?: () => void
|
|
}
|
|
|
|
function SubscriptionStatusBadge(props: {
|
|
sub: UserSubscriptionRecord['subscription']
|
|
t: (key: string) => string
|
|
}) {
|
|
// eslint-disable-next-line react-hooks/purity
|
|
const now = Date.now() / 1000
|
|
const isExpired = (props.sub.end_time || 0) > 0 && props.sub.end_time < now
|
|
const isActive = props.sub.status === 'active' && !isExpired
|
|
if (isActive)
|
|
return (
|
|
<StatusBadge
|
|
label={props.t('Active')}
|
|
variant='success'
|
|
copyable={false}
|
|
/>
|
|
)
|
|
if (props.sub.status === 'cancelled')
|
|
return (
|
|
<StatusBadge
|
|
label={props.t('Invalidated')}
|
|
variant='neutral'
|
|
copyable={false}
|
|
/>
|
|
)
|
|
return (
|
|
<StatusBadge
|
|
label={props.t('Expired')}
|
|
variant='neutral'
|
|
copyable={false}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export function UserSubscriptionsDialog(props: Props) {
|
|
const { t } = useTranslation()
|
|
const [loading, setLoading] = useState(false)
|
|
const [creating, setCreating] = useState(false)
|
|
const [plans, setPlans] = useState<PlanRecord[]>([])
|
|
const [subs, setSubs] = useState<UserSubscriptionRecord[]>([])
|
|
const [selectedPlanId, setSelectedPlanId] = useState<string>('')
|
|
const [confirmAction, setConfirmAction] = useState<{
|
|
type: 'invalidate' | 'delete'
|
|
subId: number
|
|
} | null>(null)
|
|
|
|
const planTitleMap = useMemo(() => {
|
|
const map = new Map<number, string>()
|
|
plans.forEach((p) => {
|
|
if (p.plan.id) map.set(p.plan.id, p.plan.title || `#${p.plan.id}`)
|
|
})
|
|
return map
|
|
}, [plans])
|
|
|
|
const loadData = useCallback(async () => {
|
|
if (!props.user?.id) return
|
|
setLoading(true)
|
|
try {
|
|
const [plansRes, subsRes] = await Promise.all([
|
|
getAdminPlans(),
|
|
getUserSubscriptions(props.user.id),
|
|
])
|
|
if (plansRes.success) setPlans(plansRes.data || [])
|
|
if (subsRes.success) setSubs(subsRes.data || [])
|
|
} catch {
|
|
toast.error(t('Loading failed'))
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [props.user?.id, t])
|
|
|
|
useEffect(() => {
|
|
if (props.open && props.user?.id) {
|
|
setSelectedPlanId('')
|
|
loadData()
|
|
}
|
|
}, [props.open, props.user?.id, loadData])
|
|
|
|
const handleCreate = async () => {
|
|
if (!props.user?.id || !selectedPlanId) {
|
|
toast.error(t('Please select a subscription plan'))
|
|
return
|
|
}
|
|
setCreating(true)
|
|
try {
|
|
const res = await createUserSubscription(props.user.id, {
|
|
plan_id: Number(selectedPlanId),
|
|
})
|
|
if (res.success) {
|
|
toast.success(res.data?.message || t('Added successfully'))
|
|
setSelectedPlanId('')
|
|
await loadData()
|
|
props.onSuccess?.()
|
|
}
|
|
} catch {
|
|
toast.error(t('Request failed'))
|
|
} finally {
|
|
setCreating(false)
|
|
}
|
|
}
|
|
|
|
const handleConfirmAction = async () => {
|
|
if (!confirmAction) return
|
|
try {
|
|
if (confirmAction.type === 'invalidate') {
|
|
const res = await invalidateUserSubscription(confirmAction.subId)
|
|
if (res.success) {
|
|
toast.success(res.data?.message || t('Has been invalidated'))
|
|
await loadData()
|
|
props.onSuccess?.()
|
|
}
|
|
} else {
|
|
const res = await deleteUserSubscription(confirmAction.subId)
|
|
if (res.success) {
|
|
toast.success(t('Deleted'))
|
|
await loadData()
|
|
props.onSuccess?.()
|
|
}
|
|
}
|
|
} catch {
|
|
toast.error(t('Operation failed'))
|
|
} finally {
|
|
setConfirmAction(null)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Sheet open={props.open} onOpenChange={props.onOpenChange}>
|
|
<SheetContent className='overflow-y-auto sm:max-w-2xl'>
|
|
<SheetHeader>
|
|
<SheetTitle>{t('User Subscription Management')}</SheetTitle>
|
|
<SheetDescription>
|
|
{props.user?.username || '-'} (ID: {props.user?.id || '-'})
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
|
|
<div className='mt-4 space-y-4'>
|
|
<div className='flex gap-2'>
|
|
<Select
|
|
items={[
|
|
...plans.map((p) => ({
|
|
value: String(p.plan.id),
|
|
label: (
|
|
<>
|
|
{p.plan.title}($
|
|
{Number(p.plan.price_amount || 0).toFixed(2)})
|
|
</>
|
|
),
|
|
})),
|
|
]}
|
|
value={selectedPlanId}
|
|
onValueChange={(v) => v !== null && setSelectedPlanId(v)}
|
|
>
|
|
<SelectTrigger className='flex-1'>
|
|
<SelectValue placeholder={t('Select subscription plan')} />
|
|
</SelectTrigger>
|
|
<SelectContent alignItemWithTrigger={false}>
|
|
<SelectGroup>
|
|
{plans.map((p) => (
|
|
<SelectItem key={p.plan.id} value={String(p.plan.id)}>
|
|
{p.plan.title} ($
|
|
{Number(p.plan.price_amount || 0).toFixed(2)})
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
onClick={handleCreate}
|
|
disabled={creating || !selectedPlanId}
|
|
>
|
|
<Plus className='mr-1 h-4 w-4' />
|
|
{t('Add subscription')}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className='rounded-md border'>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>ID</TableHead>
|
|
<TableHead>{t('Plan')}</TableHead>
|
|
<TableHead>{t('Status')}</TableHead>
|
|
<TableHead>{t('Validity')}</TableHead>
|
|
<TableHead>{t('Total Quota')}</TableHead>
|
|
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className='py-8 text-center'>
|
|
{t('Loading...')}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : subs.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={6}
|
|
className='text-muted-foreground py-8 text-center'
|
|
>
|
|
{t('No subscription records')}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
subs.map((record) => {
|
|
const sub = record.subscription
|
|
const now = Date.now() / 1000
|
|
const isExpired =
|
|
(sub.end_time || 0) > 0 && sub.end_time < now
|
|
const isActive = sub.status === 'active' && !isExpired
|
|
const total = Number(sub.amount_total || 0)
|
|
const used = Number(sub.amount_used || 0)
|
|
|
|
return (
|
|
<TableRow key={sub.id}>
|
|
<TableCell>#{sub.id}</TableCell>
|
|
<TableCell>
|
|
<div>
|
|
<div className='font-medium'>
|
|
{planTitleMap.get(sub.plan_id) ||
|
|
`#${sub.plan_id}`}
|
|
</div>
|
|
<div className='text-muted-foreground text-xs'>
|
|
{t('Source')}: {sub.source || '-'}
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<SubscriptionStatusBadge sub={sub} t={t} />
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className='text-xs'>
|
|
<div>
|
|
{t('Start')}: {formatTimestamp(sub.start_time)}
|
|
</div>
|
|
<div>
|
|
{t('End')}: {formatTimestamp(sub.end_time)}
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
{total > 0 ? `${used}/${total}` : t('Unlimited')}
|
|
</TableCell>
|
|
<TableCell className='text-right'>
|
|
<div className='flex justify-end gap-1'>
|
|
<Button
|
|
size='sm'
|
|
variant='outline'
|
|
disabled={!isActive}
|
|
onClick={() =>
|
|
setConfirmAction({
|
|
type: 'invalidate',
|
|
subId: sub.id,
|
|
})
|
|
}
|
|
>
|
|
{t('Invalidate')}
|
|
</Button>
|
|
<Button
|
|
size='sm'
|
|
variant='destructive'
|
|
onClick={() =>
|
|
setConfirmAction({
|
|
type: 'delete',
|
|
subId: sub.id,
|
|
})
|
|
}
|
|
>
|
|
{t('Delete')}
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
)
|
|
})
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
{confirmAction && (
|
|
<ConfirmDialog
|
|
open
|
|
onOpenChange={(v) => !v && setConfirmAction(null)}
|
|
title={
|
|
confirmAction.type === 'invalidate'
|
|
? t('Confirm invalidate')
|
|
: t('Confirm delete')
|
|
}
|
|
desc={
|
|
confirmAction.type === 'invalidate'
|
|
? t(
|
|
'After invalidating, this subscription will be immediately deactivated. Historical records are not affected. Continue?'
|
|
)
|
|
: t(
|
|
'Deleting will permanently remove this subscription record (including benefit details). Continue?'
|
|
)
|
|
}
|
|
handleConfirm={handleConfirmAction}
|
|
destructive={confirmAction.type === 'delete'}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|