feat(system): SID rules/lucky-numbers/assign, locales CRUD, devices list, robot session tokens

This commit is contained in:
2026-07-07 22:23:21 +08:00
parent 21ef5c1c37
commit 36df03baad
8 changed files with 736 additions and 11 deletions
+275
View File
@@ -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>
)
}