Files
gig-admin/src/routes/_authed/sid.tsx
T

326 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createFileRoute } from '@tanstack/react-router'
import { PERM, requirePermission } from '@/auth/permissions'
import { useCan } from '@/auth/store'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import type { LuckyNumber, SidCheckResult, SidRule } from '@/api/types'
import {
assignSid,
checkSid,
createLuckyNumber,
deleteLuckyNumber,
fetchLuckyNumbers,
fetchSidRules,
updateLuckyNumber,
updateSidRule,
} from '@/api/modules/sid'
import { handleApiError } from '@/lib/errors'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { PageHeader } from '@/components/page-header'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
export const Route = createFileRoute('/_authed/sid')({
beforeLoad: () => requirePermission(PERM.SID_MANAGE),
component: SidPage,
})
function SidPage() {
return (
<div>
<PageHeader title="SID 管理" description="靓号规则、幸运号码与手动分配" />
<Tabs defaultValue="rules">
<TabsList>
<TabsTrigger value="rules">保留规则</TabsTrigger>
<TabsTrigger value="lucky">幸运号码</TabsTrigger>
<TabsTrigger value="assign">查询 / 分配</TabsTrigger>
</TabsList>
<TabsContent value="rules" className="mt-4">
<RulesTab />
</TabsContent>
<TabsContent value="lucky" className="mt-4">
<LuckyTab />
</TabsContent>
<TabsContent value="assign" className="mt-4">
<AssignTab />
</TabsContent>
</Tabs>
</div>
)
}
function RulesTab() {
const queryClient = useQueryClient()
const rules = useQuery({ queryKey: ['sid', 'rules'], queryFn: fetchSidRules })
const toggle = async (rule: SidRule, enabled: boolean) => {
try {
await updateSidRule(rule.key, { enabled, description: rule.description })
toast.success(`规则「${rule.key}」已${enabled ? '启用' : '停用'}`)
void queryClient.invalidateQueries({ queryKey: ['sid', 'rules'] })
} catch (error) {
handleApiError(error)
}
}
if (rules.isPending) return <p className="text-sm text-muted-foreground">正在加载保留规则…</p>
if (rules.isError) {
return (
<div className="text-sm text-destructive">
<p>保留规则加载失败。</p>
<Button className="mt-2" variant="outline" size="sm" onClick={() => void rules.refetch()}>
重新加载
</Button>
</div>
)
}
return (
<div className="grid gap-3 md:grid-cols-2">
{(rules.data?.data ?? []).map((rule) => (
<Card key={rule.key}>
<CardContent className="flex items-center justify-between gap-4">
<div>
<div className="font-mono text-sm font-medium">{rule.key}</div>
<p className="mt-0.5 text-sm text-muted-foreground">{rule.description ?? '—'}</p>
</div>
<Switch checked={rule.enabled} onCheckedChange={(v) => toggle(rule, v)} />
</CardContent>
</Card>
))}
{rules.isSuccess && (rules.data?.data ?? []).length === 0 && (
<p className="text-sm text-muted-foreground">暂无规则</p>
)}
</div>
)
}
function LuckyTab() {
const queryClient = useQueryClient()
const numbers = useQuery({ queryKey: ['sid', 'lucky'], queryFn: fetchLuckyNumbers })
const [draft, setDraft] = useState({ number: '', category: '' })
const refresh = () => queryClient.invalidateQueries({ queryKey: ['sid', 'lucky'] })
const add = async () => {
try {
await createLuckyNumber({
number: draft.number,
category: draft.category || null,
enabled: true,
})
toast.success(`已添加幸运号码 ${draft.number}`)
setDraft({ number: '', category: '' })
void refresh()
} catch (error) {
handleApiError(error)
}
}
const toggle = async (row: LuckyNumber, enabled: boolean) => {
try {
await updateLuckyNumber(row.id, { enabled })
void refresh()
} catch (error) {
handleApiError(error)
}
}
const remove = async (row: LuckyNumber) => {
try {
await deleteLuckyNumber(row.id)
toast.success(`已删除 ${row.number}`)
void refresh()
} catch (error) {
handleApiError(error)
}
}
if (numbers.isPending) return <p className="text-sm text-muted-foreground">正在加载幸运号码…</p>
if (numbers.isError) {
return (
<div className="text-sm text-destructive">
<p>幸运号码加载失败。</p>
<Button className="mt-2" variant="outline" size="sm" onClick={() => void numbers.refetch()}>
重新加载
</Button>
</div>
)
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-end gap-3">
<div className="grid gap-1.5">
<Label htmlFor="lucky-number">号码(3 位)</Label>
<Input
id="lucky-number"
className="w-32 font-mono"
maxLength={3}
inputMode="numeric"
pattern="[0-9]{3}"
value={draft.number}
onChange={(e) => setDraft((d) => ({ ...d, number: e.target.value.replace(/\D/g, '') }))}
placeholder="888"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="lucky-category">分类(可选)</Label>
<Input
id="lucky-category"
className="w-40"
value={draft.category}
onChange={(e) => setDraft((d) => ({ ...d, category: e.target.value }))}
placeholder="豹子号"
/>
</div>
<Button onClick={add} disabled={draft.number.length !== 3}>
<Plus className="size-4" />
添加
</Button>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{(numbers.data?.data ?? []).map((row) => (
<div key={row.id} className="flex items-center justify-between rounded-lg border px-4 py-3">
<div className="flex items-center gap-3">
<span className="font-mono text-lg font-semibold tabular-nums">{row.number}</span>
{row.category && <Badge variant="secondary">{row.category}</Badge>}
</div>
<div className="flex items-center gap-2">
<Switch checked={row.enabled} onCheckedChange={(v) => toggle(row, v)} />
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon-sm" aria-label="删除">
<Trash2 className="size-4 text-destructive" />
</Button>
}
title={`删除幸运号码 ${row.number}`}
confirmLabel="删除"
destructive
onConfirm={() => remove(row)}
/>
</div>
</div>
))}
</div>
{numbers.isSuccess && (numbers.data?.data ?? []).length === 0 && (
<p className="text-sm text-muted-foreground">暂无幸运号码</p>
)}
</div>
)
}
function AssignTab() {
const canOverride = useCan(PERM.SID_OVERRIDE)
const [sid, setSid] = useState('')
const [checkResult, setCheckResult] = useState<SidCheckResult | null>(null)
const [userUid, setUserUid] = useState('')
const [busy, setBusy] = useState(false)
const validSid = /^[A-HJ-NPR-Z]{3}-?\d{6}$/i.test(sid)
const validUid = /^[0-9A-HJKMNP-TV-Z]{26}$/i.test(userUid)
const check = async () => {
setBusy(true)
setCheckResult(null)
try {
const res = await checkSid(sid)
setCheckResult(res.data)
} catch (error) {
handleApiError(error)
} finally {
setBusy(false)
}
}
const assign = async () => {
setBusy(true)
try {
await assignSid(userUid, sid)
toast.success(`已将 SID ${sid} 分配给 ${userUid}`)
} catch (error) {
handleApiError(error)
} finally {
setBusy(false)
}
}
return (
<Card className="max-w-lg">
<CardHeader>
<CardTitle className="text-base">SID 查询与手动分配</CardTitle>
<CardDescription>先查询保留状态,再分配给指定用户</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex items-end gap-2">
<div className="grid flex-1 gap-1.5">
<Label htmlFor="sid-input">SID</Label>
<Input
id="sid-input"
className="font-mono"
value={sid}
onChange={(e) => {
setSid(e.target.value.toUpperCase())
setCheckResult(null)
}}
placeholder="ABC-668888"
/>
</div>
<Button variant="outline" onClick={check} disabled={!validSid || busy}>
查询
</Button>
</div>
{checkResult && (
<div className="rounded-md border bg-muted/40 px-3 py-2 text-sm">
<span className="font-mono">{checkResult.sid}</span>{' '}
{checkResult.reserved ? (
<Badge className="ml-1" variant="destructive">
保留号段
</Badge>
) : (
<Badge className="ml-1" variant="secondary">
可分配
</Badge>
)}
{checkResult.reserved && !canOverride && (
<p className="mt-1 text-xs text-muted-foreground">
分配保留号段需要 admin:sid:override 权限
</p>
)}
</div>
)}
<div className="grid gap-1.5">
<Label htmlFor="assign-uid">用户 UID</Label>
<Input
id="assign-uid"
className="font-mono"
maxLength={26}
value={userUid}
onChange={(e) => setUserUid(e.target.value.toUpperCase())}
placeholder="01JXXXXXXXXXXXXXXXXXXXXXXX"
/>
</div>
<Button
onClick={assign}
disabled={
!validSid ||
!validUid ||
!checkResult ||
busy ||
(checkResult.reserved && !canOverride)
}
>
分配 SID
</Button>
</CardContent>
</Card>
)
}