feat(system): SID rules/lucky-numbers/assign, locales CRUD, devices list, robot session tokens
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
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')({
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
value={draft.number}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, number: e.target.value }))}
|
||||
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 [sid, setSid] = useState('')
|
||||
const [checkResult, setCheckResult] = useState<SidCheckResult | null>(null)
|
||||
const [userUid, setUserUid] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
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)}
|
||||
placeholder="668888"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={check} disabled={!sid || 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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="assign-uid">用户 UID</Label>
|
||||
<Input
|
||||
id="assign-uid"
|
||||
className="font-mono"
|
||||
value={userUid}
|
||||
onChange={(e) => setUserUid(e.target.value)}
|
||||
placeholder="01JXXXXXXXXXXXXXXXXXXXXXXX"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={assign} disabled={!sid || !userUid || busy}>
|
||||
分配 SID
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user