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

View 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)
}

View 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
View 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 })
}

View File

@ -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
}

View 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>
)
}

View 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>
)
}

View 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
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>
)
}