diff --git a/src/api/modules/devices.ts b/src/api/modules/devices.ts new file mode 100644 index 0000000..44fcf5d --- /dev/null +++ b/src/api/modules/devices.ts @@ -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>('/api/admin/v1/notification/devices', params) +} diff --git a/src/api/modules/locales.ts b/src/api/modules/locales.ts new file mode 100644 index 0000000..5095ee5 --- /dev/null +++ b/src/api/modules/locales.ts @@ -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>(BASE) +} + +export function createLocale(payload: LocalePayload) { + return api.post>(BASE, payload) +} + +export function updateLocale(code: string, payload: Partial) { + return api.put>(`${BASE}/${code}`, payload) +} + +export function deleteLocale(code: string) { + return api.delete>(`${BASE}/${code}`) +} diff --git a/src/api/modules/sid.ts b/src/api/modules/sid.ts new file mode 100644 index 0000000..9d51f88 --- /dev/null +++ b/src/api/modules/sid.ts @@ -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>(`${BASE}/rules`) +} + +export function updateSidRule(key: string, payload: { enabled: boolean; description?: string | null }) { + return api.put>(`${BASE}/rules/${key}`, payload) +} + +export function fetchLuckyNumbers() { + return api.get>(`${BASE}/lucky-numbers`) +} + +export function createLuckyNumber(payload: { number: string; category?: string | null; enabled?: boolean }) { + return api.post>(`${BASE}/lucky-numbers`, payload) +} + +export function updateLuckyNumber( + id: number, + payload: { category?: string | null; enabled?: boolean }, +) { + return api.put>(`${BASE}/lucky-numbers/${id}`, payload) +} + +export function deleteLuckyNumber(id: number) { + return api.delete>(`${BASE}/lucky-numbers/${id}`) +} + +export function checkSid(sid: string) { + return api.post>(`${BASE}/check`, { sid }) +} + +export function assignSid(userUid: string, sid: string) { + return api.post>(`${BASE}/assign`, { user_uid: userUid, sid }) +} diff --git a/src/api/types.ts b/src/api/types.ts index d6da93a..282caaf 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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 } diff --git a/src/routes/_authed/devices.tsx b/src/routes/_authed/devices.tsx new file mode 100644 index 0000000..91b70f4 --- /dev/null +++ b/src/routes/_authed/devices.tsx @@ -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[] = [ + { + header: '设备', + cell: ({ row }) => ( +
+
{row.original.model ?? row.original.device_id ?? '—'}
+ + {String(row.original.device_id ?? row.original.id)} + +
+ ), + }, + { + header: '平台', + cell: ({ row }) => {row.original.platform ?? '—'}, + }, + { + header: '用户', + cell: ({ row }) => {row.original.user_uid ?? '未绑定'}, + }, + { + header: '在线', + cell: ({ row }) => + row.original.is_online ? ( + 在线 + ) : ( + 离线 + ), + }, + { + 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('all') + const list = useListPage( + 'devices', + fetchDevices, + platform === 'all' ? {} : { platform }, + ) + + return ( +
+ + + + + + 全部平台 + iOS + Android + Web + Desktop + + + } + /> + +
+ ) +} diff --git a/src/routes/_authed/locales.tsx b/src/routes/_authed/locales.tsx new file mode 100644 index 0000000..bb77e6f --- /dev/null +++ b/src/routes/_authed/locales.tsx @@ -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[] = [ + { + header: '代码', + cell: ({ row }) => {row.original.code}, + }, + { header: '名称', accessorKey: 'name' }, + { header: '本地名称', accessorKey: 'native_name' }, + { header: '排序', accessorKey: 'sort_order' }, + { + header: '启用', + cell: ({ row }) => ( + toggle(row.original, v)} + /> + ), + }, + { + header: '操作', + cell: ({ row }) => ( + + + + } + title={`删除语言 ${row.original.code}?`} + confirmLabel="删除" + destructive + onConfirm={() => remove(row.original)} + /> + ), + }, + ] + + return ( +
+ setDialogOpen(true)}> + + 添加语言 + + } + /> + + void refresh()} + /> +
+ ) +} + +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 ( + + + + 添加语言 + +
+
+ + setDraft((d) => ({ ...d, code: e.target.value }))} + /> +
+
+ + setDraft((d) => ({ ...d, name: e.target.value }))} + /> +
+
+ + setDraft((d) => ({ ...d, native_name: e.target.value }))} + /> +
+
+ + + + +
+
+ ) +} diff --git a/src/routes/_authed/robots.tsx b/src/routes/_authed/robots.tsx new file mode 100644 index 0000000..2475131 --- /dev/null +++ b/src/routes/_authed/robots.tsx @@ -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(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 ( +
+ + + + 签发会话令牌 + + 仅限 is_robot 账号;真实用户账号会被后端拒绝(INVALID_ROBOT_ACCOUNT)。 + + + +
+ + setUid(e.target.value)} + placeholder="01JXXXXXXXXXXXXXXXXXXXXXXX" + /> +
+ +
+
+ + +
+ ) +} diff --git a/src/routes/_authed/sid.tsx b/src/routes/_authed/sid.tsx new file mode 100644 index 0000000..95874c3 --- /dev/null +++ b/src/routes/_authed/sid.tsx @@ -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 ( +
+ + + + 保留规则 + 幸运号码 + 查询 / 分配 + + + + + + + + + + + +
+ ) +} + +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 ( +
+ {(rules.data?.data ?? []).map((rule) => ( + + +
+
{rule.key}
+

{rule.description ?? '—'}

+
+ toggle(rule, v)} /> +
+
+ ))} + {rules.isSuccess && (rules.data?.data ?? []).length === 0 && ( +

暂无规则

+ )} +
+ ) +} + +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 ( +
+
+
+ + setDraft((d) => ({ ...d, number: e.target.value }))} + placeholder="888" + /> +
+
+ + setDraft((d) => ({ ...d, category: e.target.value }))} + placeholder="豹子号" + /> +
+ +
+ +
+ {(numbers.data?.data ?? []).map((row) => ( +
+
+ {row.number} + {row.category && {row.category}} +
+
+ toggle(row, v)} /> + + + + } + title={`删除幸运号码 ${row.number}?`} + confirmLabel="删除" + destructive + onConfirm={() => remove(row)} + /> +
+
+ ))} +
+ {numbers.isSuccess && (numbers.data?.data ?? []).length === 0 && ( +

暂无幸运号码

+ )} +
+ ) +} + +function AssignTab() { + const [sid, setSid] = useState('') + const [checkResult, setCheckResult] = useState(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 ( + + + SID 查询与手动分配 + 先查询保留状态,再分配给指定用户 + + +
+
+ + setSid(e.target.value)} + placeholder="668888" + /> +
+ +
+ {checkResult && ( +
+ {checkResult.sid}{' '} + {checkResult.reserved ? ( + + 保留号段 + + ) : ( + + 可分配 + + )} +
+ )} +
+ + setUserUid(e.target.value)} + placeholder="01JXXXXXXXXXXXXXXXXXXXXXXX" + /> +
+ +
+
+ ) +}