326 lines
10 KiB
TypeScript
326 lines
10 KiB
TypeScript
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>
|
||
)
|
||
}
|