From 1243ed6c92b763416687ea7e9aa710a606125228 Mon Sep 17 00:00:00 2001 From: kongkx Date: Thu, 10 Sep 2026 20:09:06 +0800 Subject: [PATCH] feat(notification): manage push applications --- src/api/modules/push-apps.ts | 10 +++++ src/api/types.ts | 35 +++++++++++++++++ src/auth/permissions.ts | 1 + src/components/layout/nav.ts | 1 + .../notification/push-app-edit-sheet.tsx | 34 +++++++++++++++++ src/routeTree.gen.ts | 21 ++++++++++ src/routes/_authed/push-apps.tsx | 38 +++++++++++++++++++ 7 files changed, 140 insertions(+) create mode 100644 src/api/modules/push-apps.ts create mode 100644 src/features/notification/push-app-edit-sheet.tsx create mode 100644 src/routes/_authed/push-apps.tsx diff --git a/src/api/modules/push-apps.ts b/src/api/modules/push-apps.ts new file mode 100644 index 0000000..6612c71 --- /dev/null +++ b/src/api/modules/push-apps.ts @@ -0,0 +1,10 @@ +import { api } from '@/api/client' +import type { ApiResponse, PushApp, PushAppPayload, PushAppVerification } from '@/api/types' + +const BASE = '/api/admin/v1/notification/push-apps' + +export function fetchPushApps() { return api.get>(BASE) } +export function createPushApp(payload: PushAppPayload) { return api.post>(BASE, payload) } +export function updatePushApp(id: string, payload: Partial) { return api.put>(`${BASE}/${id}`, payload) } +export function deletePushApp(id: string) { return api.delete>(`${BASE}/${id}`) } +export function verifyPushApp(id: string) { return api.post>(`${BASE}/${id}/verify`) } diff --git a/src/api/types.ts b/src/api/types.ts index 531e000..877ed7b 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -410,6 +410,41 @@ export interface NotificationDeviceStats { platform_stats: Partial> } +export type PushProvider = 'apn' | 'fcm' | 'hms' | 'mipush' + +export interface PushApp { + kind: 'push_app' + id: string + app_key: string + provider: PushProvider + platform: 'ios' | 'android' + label: string | null + topic: string | null + settings: Record + is_active: boolean + credentials_file: string | null + credentials_present: boolean + created_at: string + updated_at: string +} + +export interface PushAppPayload { + app_key: string + provider: PushProvider + label?: string | null + topic?: string | null + settings?: Record | null + credentials_path?: string | null + is_active?: boolean +} + +export interface PushAppVerification { + kind: 'push_app_verification' + id: string + checks: Record + ok: boolean +} + export type ReportStatus = 'open' | 'resolved' | 'dismissed' export interface ReportUser { diff --git a/src/auth/permissions.ts b/src/auth/permissions.ts index 0c73a2e..5a254fa 100644 --- a/src/auth/permissions.ts +++ b/src/auth/permissions.ts @@ -29,6 +29,7 @@ export const PERM = { SID_MANAGE: 'admin:sid:manage', SID_OVERRIDE: 'admin:sid:override', NOTIFICATION_READ: 'admin:notification:read', + PUSH_APP_MANAGE: 'auth:push-app:manage', RESUME_READ: 'admin:resume:read', RESUME_MANAGE: 'admin:resume:manage', ROBOT_TOKEN_ISSUE: 'auth:robot-token:issue', diff --git a/src/components/layout/nav.ts b/src/components/layout/nav.ts index 7780ac9..3d9f196 100644 --- a/src/components/layout/nav.ts +++ b/src/components/layout/nav.ts @@ -115,6 +115,7 @@ export const NAV_GROUPS: NavGroup[] = [ { label: 'SID 管理', to: '/sid', icon: Hash, permission: 'admin:sid:manage' }, { label: '语言设置', to: '/locales', icon: Languages, permission: 'admin:locale:read' }, { label: '推送设备', to: '/devices', icon: Smartphone, permission: 'admin:notification:read' }, + { label: '推送应用', to: '/push-apps', icon: Send, permission: 'admin:notification:read' }, { label: '机器人会话', to: '/robots', icon: Inbox, permission: 'auth:robot-token:issue' }, ], }, diff --git a/src/features/notification/push-app-edit-sheet.tsx b/src/features/notification/push-app-edit-sheet.tsx new file mode 100644 index 0000000..955edd1 --- /dev/null +++ b/src/features/notification/push-app-edit-sheet.tsx @@ -0,0 +1,34 @@ +import { useEffect } from 'react' +import { zodResolver } from '@hookform/resolvers/zod' +import { Controller, useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { z } from 'zod' +import { createPushApp, updatePushApp } from '@/api/modules/push-apps' +import type { PushApp, PushProvider } from '@/api/types' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet' +import { handleApiError } from '@/lib/errors' + +const schema = z.object({ app_key: z.string().trim().min(1, '请填写应用标识').max(50), provider: z.enum(['apn', 'fcm', 'hms']), label: z.string().trim().max(100), topic: z.string().trim().max(100), credentials_path: z.string().trim().max(255), project_id: z.string().trim(), key_id: z.string().trim(), team_id: z.string().trim(), production: z.boolean(), is_active: z.boolean() }) +type Values = z.infer +interface Props { app: PushApp | null; creating: boolean; onOpenChange: (open: boolean) => void; onSaved: () => void } + +export function PushAppEditSheet({ app, creating, onOpenChange, onSaved }: Props) { + const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults() }) + const provider = form.watch('provider') + useEffect(() => { form.reset(defaults(app)) }, [app, creating, form]) + const submit = async (values: Values) => { + const settings = values.provider === 'apn' ? { key_id: values.key_id || undefined, team_id: values.team_id || undefined, production: values.production } : { project_id: values.project_id || undefined } + const payload = { app_key: values.app_key.trim(), provider: values.provider, label: values.label.trim() || null, topic: values.topic.trim() || null, credentials_path: values.credentials_path.trim() || null, settings, is_active: values.is_active } + try { if (app) await updatePushApp(app.id, payload); else await createPushApp(payload); toast.success(app ? '推送应用已更新' : '推送应用已创建'); onSaved(); onOpenChange(false) } catch (error) { handleApiError(error, form.setError) } + } + return {app ? '编辑推送应用' : '新建推送应用'}
} />{provider === 'apn' ? <> : }
+} + +function defaults(app?: PushApp | null): Values { const settings = app?.settings ?? {}; return { app_key: app?.app_key ?? '', provider: (app?.provider === 'mipush' ? 'fcm' : app?.provider ?? 'apn') as Exclude, label: app?.label ?? '', topic: app?.topic ?? '', credentials_path: app?.credentials_file ?? '', project_id: String(settings.project_id ?? ''), key_id: String(settings.key_id ?? ''), team_id: String(settings.team_id ?? ''), production: Boolean(settings.production), is_active: app?.is_active ?? true } } +function Field({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) { return
{children}{error &&

{error}

}
} +function Check({ control, name, label }: { control: ReturnType>['control']; name: 'production' | 'is_active'; label: string }) { return } /> } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index b8185c0..2951392 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as AuthedIndexRouteImport } from './routes/_authed/index' import { Route as AuthedSidRouteImport } from './routes/_authed/sid' import { Route as AuthedRolesRouteImport } from './routes/_authed/roles' import { Route as AuthedRobotsRouteImport } from './routes/_authed/robots' +import { Route as AuthedPushAppsRouteImport } from './routes/_authed/push-apps' import { Route as AuthedMinioConsoleRouteImport } from './routes/_authed/minio-console' import { Route as AuthedLocalesRouteImport } from './routes/_authed/locales' import { Route as AuthedFilexEventsRouteImport } from './routes/_authed/filex-events' @@ -77,6 +78,11 @@ const AuthedRobotsRoute = AuthedRobotsRouteImport.update({ path: '/robots', getParentRoute: () => AuthedRoute, } as any) +const AuthedPushAppsRoute = AuthedPushAppsRouteImport.update({ + id: '/push-apps', + path: '/push-apps', + getParentRoute: () => AuthedRoute, +} as any) const AuthedMinioConsoleRoute = AuthedMinioConsoleRouteImport.update({ id: '/minio-console', path: '/minio-console', @@ -227,6 +233,7 @@ export interface FileRoutesByFullPath { '/filex-events': typeof AuthedFilexEventsRoute '/locales': typeof AuthedLocalesRoute '/minio-console': typeof AuthedMinioConsoleRoute + '/push-apps': typeof AuthedPushAppsRoute '/robots': typeof AuthedRobotsRoute '/roles': typeof AuthedRolesRoute '/sid': typeof AuthedSidRoute @@ -260,6 +267,7 @@ export interface FileRoutesByTo { '/filex-events': typeof AuthedFilexEventsRoute '/locales': typeof AuthedLocalesRoute '/minio-console': typeof AuthedMinioConsoleRoute + '/push-apps': typeof AuthedPushAppsRoute '/robots': typeof AuthedRobotsRoute '/roles': typeof AuthedRolesRoute '/sid': typeof AuthedSidRoute @@ -296,6 +304,7 @@ export interface FileRoutesById { '/_authed/filex-events': typeof AuthedFilexEventsRoute '/_authed/locales': typeof AuthedLocalesRoute '/_authed/minio-console': typeof AuthedMinioConsoleRoute + '/_authed/push-apps': typeof AuthedPushAppsRoute '/_authed/robots': typeof AuthedRobotsRoute '/_authed/roles': typeof AuthedRolesRoute '/_authed/sid': typeof AuthedSidRoute @@ -333,6 +342,7 @@ export interface FileRouteTypes { | '/filex-events' | '/locales' | '/minio-console' + | '/push-apps' | '/robots' | '/roles' | '/sid' @@ -366,6 +376,7 @@ export interface FileRouteTypes { | '/filex-events' | '/locales' | '/minio-console' + | '/push-apps' | '/robots' | '/roles' | '/sid' @@ -401,6 +412,7 @@ export interface FileRouteTypes { | '/_authed/filex-events' | '/_authed/locales' | '/_authed/minio-console' + | '/_authed/push-apps' | '/_authed/robots' | '/_authed/roles' | '/_authed/sid' @@ -481,6 +493,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedRobotsRouteImport parentRoute: typeof AuthedRoute } + '/_authed/push-apps': { + id: '/_authed/push-apps' + path: '/push-apps' + fullPath: '/push-apps' + preLoaderRoute: typeof AuthedPushAppsRouteImport + parentRoute: typeof AuthedRoute + } '/_authed/minio-console': { id: '/_authed/minio-console' path: '/minio-console' @@ -676,6 +695,7 @@ interface AuthedRouteChildren { AuthedFilexEventsRoute: typeof AuthedFilexEventsRoute AuthedLocalesRoute: typeof AuthedLocalesRoute AuthedMinioConsoleRoute: typeof AuthedMinioConsoleRoute + AuthedPushAppsRoute: typeof AuthedPushAppsRoute AuthedRobotsRoute: typeof AuthedRobotsRoute AuthedRolesRoute: typeof AuthedRolesRoute AuthedSidRoute: typeof AuthedSidRoute @@ -709,6 +729,7 @@ const AuthedRouteChildren: AuthedRouteChildren = { AuthedFilexEventsRoute: AuthedFilexEventsRoute, AuthedLocalesRoute: AuthedLocalesRoute, AuthedMinioConsoleRoute: AuthedMinioConsoleRoute, + AuthedPushAppsRoute: AuthedPushAppsRoute, AuthedRobotsRoute: AuthedRobotsRoute, AuthedRolesRoute: AuthedRolesRoute, AuthedSidRoute: AuthedSidRoute, diff --git a/src/routes/_authed/push-apps.tsx b/src/routes/_authed/push-apps.tsx new file mode 100644 index 0000000..90e421f --- /dev/null +++ b/src/routes/_authed/push-apps.tsx @@ -0,0 +1,38 @@ +import { useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import type { ColumnDef } from '@tanstack/react-table' +import { toast } from 'sonner' +import { deletePushApp, fetchPushApps, verifyPushApp } from '@/api/modules/push-apps' +import type { PushApp } from '@/api/types' +import { PERM, requirePermission } from '@/auth/permissions' +import { useCan } from '@/auth/store' +import { ConfirmDialog } from '@/components/confirm-dialog' +import { DataTable } from '@/components/data-table/data-table' +import { PageHeader } from '@/components/page-header' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { PushAppEditSheet } from '@/features/notification/push-app-edit-sheet' +import { handleApiError } from '@/lib/errors' + +export const Route = createFileRoute('/_authed/push-apps')({ beforeLoad: () => requirePermission(PERM.NOTIFICATION_READ), component: PushAppsPage }) + +function PushAppsPage() { + const canManage = useCan(PERM.PUSH_APP_MANAGE) + const queryClient = useQueryClient() + const query = useQuery({ queryKey: ['push-apps'], queryFn: fetchPushApps }) + const [editing, setEditing] = useState(null) + const [creating, setCreating] = useState(false) + const refresh = () => { void queryClient.invalidateQueries({ queryKey: ['push-apps'] }) } + const verify = async (app: PushApp) => { try { const response = await verifyPushApp(app.id); const failed = Object.entries(response.data.checks).filter(([, ok]) => !ok).map(([key]) => key); response.data.ok ? toast.success('凭证验证通过') : toast.error(`验证未通过:${failed.join(', ')}`) } catch (error) { handleApiError(error) } } + const remove = async (app: PushApp) => { try { await deletePushApp(app.id); toast.success('推送应用已删除'); refresh() } catch (error) { handleApiError(error) } } + const columns: ColumnDef[] = [ + { header: '应用', cell: ({ row }) =>
{row.original.label ?? row.original.app_key}
{row.original.app_key}
}, + { header: '通道', cell: ({ row }) => {row.original.provider.toUpperCase()} }, + { header: '平台', accessorKey: 'platform' }, + { header: '凭证', cell: ({ row }) => row.original.credentials_present ? 已放置 : 缺失 }, + { header: '状态', cell: ({ row }) => row.original.is_active ? '启用' : '停用' }, + { header: '操作', cell: ({ row }) =>
{canManage && }{canManage && row.original.provider !== 'mipush' && }{canManage && 删除} title={`删除 ${row.original.app_key}/${row.original.provider}?`} confirmLabel="删除" destructive onConfirm={() => remove(row.original)} />}
}, + ] + return
setCreating(true)}>新建推送应用 : undefined} /> { if (!open) { setEditing(null); setCreating(false) } }} onSaved={refresh} />
+}