feat(system): SID rules/lucky-numbers/assign, locales CRUD, devices list, robot session tokens
This commit is contained in:
6
src/api/modules/devices.ts
Normal file
6
src/api/modules/devices.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { api } from '@/api/client'
|
||||
import type { ListParams, NotificationDevice, PaginatedResponse } from '@/api/types'
|
||||
|
||||
export function fetchDevices(params: ListParams) {
|
||||
return api.get<PaginatedResponse<NotificationDevice>>('/api/admin/v1/notification/devices', params)
|
||||
}
|
||||
28
src/api/modules/locales.ts
Normal file
28
src/api/modules/locales.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { api } from '@/api/client'
|
||||
import type { ApiResponse, LocaleRow } from '@/api/types'
|
||||
|
||||
const BASE = '/api/admin/v1/locales'
|
||||
|
||||
export interface LocalePayload {
|
||||
code?: string
|
||||
name: string
|
||||
native_name: string
|
||||
is_active?: boolean
|
||||
sort_order?: number
|
||||
}
|
||||
|
||||
export function fetchLocales() {
|
||||
return api.get<ApiResponse<LocaleRow[]>>(BASE)
|
||||
}
|
||||
|
||||
export function createLocale(payload: LocalePayload) {
|
||||
return api.post<ApiResponse<LocaleRow>>(BASE, payload)
|
||||
}
|
||||
|
||||
export function updateLocale(code: string, payload: Partial<LocalePayload>) {
|
||||
return api.put<ApiResponse<LocaleRow>>(`${BASE}/${code}`, payload)
|
||||
}
|
||||
|
||||
export function deleteLocale(code: string) {
|
||||
return api.delete<ApiResponse<null>>(`${BASE}/${code}`)
|
||||
}
|
||||
39
src/api/modules/sid.ts
Normal file
39
src/api/modules/sid.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { api } from '@/api/client'
|
||||
import type { ApiResponse, LuckyNumber, SidCheckResult, SidRule } from '@/api/types'
|
||||
|
||||
const BASE = '/api/admin/v1/sid'
|
||||
|
||||
export function fetchSidRules() {
|
||||
return api.get<ApiResponse<SidRule[]>>(`${BASE}/rules`)
|
||||
}
|
||||
|
||||
export function updateSidRule(key: string, payload: { enabled: boolean; description?: string | null }) {
|
||||
return api.put<ApiResponse<SidRule>>(`${BASE}/rules/${key}`, payload)
|
||||
}
|
||||
|
||||
export function fetchLuckyNumbers() {
|
||||
return api.get<ApiResponse<LuckyNumber[]>>(`${BASE}/lucky-numbers`)
|
||||
}
|
||||
|
||||
export function createLuckyNumber(payload: { number: string; category?: string | null; enabled?: boolean }) {
|
||||
return api.post<ApiResponse<LuckyNumber>>(`${BASE}/lucky-numbers`, payload)
|
||||
}
|
||||
|
||||
export function updateLuckyNumber(
|
||||
id: number,
|
||||
payload: { category?: string | null; enabled?: boolean },
|
||||
) {
|
||||
return api.put<ApiResponse<LuckyNumber>>(`${BASE}/lucky-numbers/${id}`, payload)
|
||||
}
|
||||
|
||||
export function deleteLuckyNumber(id: number) {
|
||||
return api.delete<ApiResponse<null>>(`${BASE}/lucky-numbers/${id}`)
|
||||
}
|
||||
|
||||
export function checkSid(sid: string) {
|
||||
return api.post<ApiResponse<SidCheckResult>>(`${BASE}/check`, { sid })
|
||||
}
|
||||
|
||||
export function assignSid(userUid: string, sid: string) {
|
||||
return api.post<ApiResponse<unknown>>(`${BASE}/assign`, { user_uid: userUid, sid })
|
||||
}
|
||||
@ -231,33 +231,49 @@ export interface StudioAgentTokenGrant extends TokenGrant {
|
||||
// ---------- sid / locales / devices ----------
|
||||
|
||||
export interface SidRule {
|
||||
id?: number
|
||||
key: string
|
||||
value: unknown
|
||||
description?: string | null
|
||||
description: string | null
|
||||
enabled: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface LuckyNumber {
|
||||
id: number | string
|
||||
id: number
|
||||
number: string
|
||||
price?: number | null
|
||||
status?: string
|
||||
reserved_for_uid?: string | null
|
||||
category: string | null
|
||||
enabled: boolean
|
||||
created_by?: string | null
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface SidCheckResult {
|
||||
sid: string
|
||||
reserved: boolean
|
||||
reasons?: string[]
|
||||
}
|
||||
|
||||
export interface LocaleRow {
|
||||
kind?: string
|
||||
code: string
|
||||
name: string
|
||||
native_name?: string | null
|
||||
enabled?: boolean
|
||||
is_default?: boolean
|
||||
native_name: string
|
||||
is_active: boolean
|
||||
sort_order: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface NotificationDevice {
|
||||
id: number | string
|
||||
user_uid: string
|
||||
device_id?: string
|
||||
user_uid?: string | null
|
||||
platform?: string
|
||||
token?: string
|
||||
model?: string | null
|
||||
is_online?: boolean
|
||||
active_sessions_count?: number
|
||||
last_seen_at?: string | null
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
100
src/routes/_authed/devices.tsx
Normal file
100
src/routes/_authed/devices.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { NotificationDevice } from '@/api/types'
|
||||
import { fetchDevices } from '@/api/modules/devices'
|
||||
import { useListPage } from '@/lib/list-page'
|
||||
import { DataTable } from '@/components/data-table/data-table'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
export const Route = createFileRoute('/_authed/devices')({
|
||||
component: DevicesPage,
|
||||
})
|
||||
|
||||
const columns: ColumnDef<NotificationDevice>[] = [
|
||||
{
|
||||
header: '设备',
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<div className="font-medium">{row.original.model ?? row.original.device_id ?? '—'}</div>
|
||||
<code className="font-mono text-xs text-muted-foreground">
|
||||
{String(row.original.device_id ?? row.original.id)}
|
||||
</code>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '平台',
|
||||
cell: ({ row }) => <Badge variant="secondary">{row.original.platform ?? '—'}</Badge>,
|
||||
},
|
||||
{
|
||||
header: '用户',
|
||||
cell: ({ row }) => <code className="font-mono text-xs">{row.original.user_uid ?? '未绑定'}</code>,
|
||||
},
|
||||
{
|
||||
header: '在线',
|
||||
cell: ({ row }) =>
|
||||
row.original.is_online ? (
|
||||
<Badge className="bg-emerald-500/12 text-emerald-700 dark:text-emerald-400">在线</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">离线</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '活跃会话',
|
||||
cell: ({ row }) => row.original.active_sessions_count ?? 0,
|
||||
},
|
||||
{
|
||||
header: '最近活跃',
|
||||
cell: ({ row }) =>
|
||||
row.original.last_seen_at ? new Date(row.original.last_seen_at).toLocaleString('zh-CN') : '—',
|
||||
},
|
||||
]
|
||||
|
||||
function DevicesPage() {
|
||||
const [platform, setPlatform] = useState<string>('all')
|
||||
const list = useListPage(
|
||||
'devices',
|
||||
fetchDevices,
|
||||
platform === 'all' ? {} : { platform },
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="推送设备"
|
||||
description="注册的通知设备与在线状态"
|
||||
actions={
|
||||
<Select value={platform} onValueChange={setPlatform}>
|
||||
<SelectTrigger size="sm" className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部平台</SelectItem>
|
||||
<SelectItem value="ios">iOS</SelectItem>
|
||||
<SelectItem value="android">Android</SelectItem>
|
||||
<SelectItem value="web">Web</SelectItem>
|
||||
<SelectItem value="desktop">Desktop</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={list.rows}
|
||||
pagination={list.pagination}
|
||||
loading={list.loading}
|
||||
onPageChange={list.setPage}
|
||||
onPageSizeChange={list.setPageSize}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
188
src/routes/_authed/locales.tsx
Normal file
188
src/routes/_authed/locales.tsx
Normal file
@ -0,0 +1,188 @@
|
||||
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 { ColumnDef } from '@tanstack/react-table'
|
||||
import type { LocaleRow } from '@/api/types'
|
||||
import { createLocale, deleteLocale, fetchLocales, updateLocale } from '@/api/modules/locales'
|
||||
import { handleApiError } from '@/lib/errors'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { DataTable } from '@/components/data-table/data-table'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
|
||||
export const Route = createFileRoute('/_authed/locales')({
|
||||
component: LocalesPage,
|
||||
})
|
||||
|
||||
function LocalesPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const locales = useQuery({ queryKey: ['locales'], queryFn: fetchLocales })
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ['locales'] })
|
||||
|
||||
const toggle = async (row: LocaleRow, active: boolean) => {
|
||||
try {
|
||||
await updateLocale(row.code, { name: row.name, native_name: row.native_name, is_active: active })
|
||||
void refresh()
|
||||
} catch (error) {
|
||||
handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (row: LocaleRow) => {
|
||||
try {
|
||||
await deleteLocale(row.code)
|
||||
toast.success(`已删除语言 ${row.code}`)
|
||||
void refresh()
|
||||
} catch (error) {
|
||||
handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnDef<LocaleRow>[] = [
|
||||
{
|
||||
header: '代码',
|
||||
cell: ({ row }) => <code className="font-mono text-xs">{row.original.code}</code>,
|
||||
},
|
||||
{ header: '名称', accessorKey: 'name' },
|
||||
{ header: '本地名称', accessorKey: 'native_name' },
|
||||
{ header: '排序', accessorKey: 'sort_order' },
|
||||
{
|
||||
header: '启用',
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
checked={row.original.is_active}
|
||||
onCheckedChange={(v) => toggle(row.original, v)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '操作',
|
||||
cell: ({ row }) => (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" aria-label="删除">
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
}
|
||||
title={`删除语言 ${row.original.code}?`}
|
||||
confirmLabel="删除"
|
||||
destructive
|
||||
onConfirm={() => remove(row.original)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="语言设置"
|
||||
description="平台支持的界面/内容语言"
|
||||
actions={
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
添加语言
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={locales.data?.data ?? []}
|
||||
loading={locales.isPending}
|
||||
emptyText="暂无语言"
|
||||
/>
|
||||
<CreateLocaleDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
onCreated={() => void refresh()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateLocaleDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreated: () => void
|
||||
}) {
|
||||
const [draft, setDraft] = useState({ code: '', name: '', native_name: '' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await createLocale({ ...draft, is_active: true })
|
||||
toast.success(`已添加语言 ${draft.code}`)
|
||||
setDraft({ code: '', name: '', native_name: '' })
|
||||
onCreated()
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
handleApiError(error)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加语言</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>代码</Label>
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="zh-CN"
|
||||
value={draft.code}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, code: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>名称</Label>
|
||||
<Input
|
||||
placeholder="Simplified Chinese"
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>本地名称</Label>
|
||||
<Input
|
||||
placeholder="简体中文"
|
||||
value={draft.native_name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, native_name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={busy || !draft.code || !draft.name || !draft.native_name}>
|
||||
{busy ? '添加中…' : '添加'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
73
src/routes/_authed/robots.tsx
Normal file
73
src/routes/_authed/robots.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { issueRobotSessionToken } from '@/api/modules/auth'
|
||||
import { handleApiError } from '@/lib/errors'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { ShowOnceTokenDialog } from '@/components/show-once-token-dialog'
|
||||
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'
|
||||
|
||||
export const Route = createFileRoute('/_authed/robots')({
|
||||
component: RobotsPage,
|
||||
})
|
||||
|
||||
function RobotsPage() {
|
||||
const [uid, setUid] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
const issue = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await issueRobotSessionToken(uid.trim())
|
||||
setToken(res.data.access_token)
|
||||
setDialogOpen(true)
|
||||
} catch (error) {
|
||||
handleApiError(error)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="机器人会话"
|
||||
description="为机器人账号签发会话令牌(用于以机器人身份操作前台)"
|
||||
/>
|
||||
<Card className="max-w-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">签发会话令牌</CardTitle>
|
||||
<CardDescription>
|
||||
仅限 is_robot 账号;真实用户账号会被后端拒绝(INVALID_ROBOT_ACCOUNT)。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-end gap-2">
|
||||
<div className="grid flex-1 gap-1.5">
|
||||
<Label htmlFor="robot-uid">机器人 UID</Label>
|
||||
<Input
|
||||
id="robot-uid"
|
||||
className="font-mono"
|
||||
value={uid}
|
||||
onChange={(e) => setUid(e.target.value)}
|
||||
placeholder="01JXXXXXXXXXXXXXXXXXXXXXXX"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={issue} disabled={!uid.trim() || busy}>
|
||||
{busy ? '签发中…' : '签发'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ShowOnceTokenDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title="机器人会话令牌"
|
||||
token={token}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
275
src/routes/_authed/sid.tsx
Normal file
275
src/routes/_authed/sid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user