feat(notification): manage push applications

This commit is contained in:
2026-09-10 20:09:06 +08:00
parent 573b0c4bc7
commit 1243ed6c92
7 changed files with 140 additions and 0 deletions
+10
View File
@@ -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<ApiResponse<PushApp[]>>(BASE) }
export function createPushApp(payload: PushAppPayload) { return api.post<ApiResponse<PushApp>>(BASE, payload) }
export function updatePushApp(id: string, payload: Partial<PushAppPayload>) { return api.put<ApiResponse<PushApp>>(`${BASE}/${id}`, payload) }
export function deletePushApp(id: string) { return api.delete<ApiResponse<null>>(`${BASE}/${id}`) }
export function verifyPushApp(id: string) { return api.post<ApiResponse<PushAppVerification>>(`${BASE}/${id}/verify`) }
+35
View File
@@ -410,6 +410,41 @@ export interface NotificationDeviceStats {
platform_stats: Partial<Record<NotificationDevice['platform'], number>>
}
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<string, unknown>
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<string, unknown> | null
credentials_path?: string | null
is_active?: boolean
}
export interface PushAppVerification {
kind: 'push_app_verification'
id: string
checks: Record<string, boolean>
ok: boolean
}
export type ReportStatus = 'open' | 'resolved' | 'dismissed'
export interface ReportUser {
+1
View File
@@ -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',
+1
View File
@@ -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' },
],
},
@@ -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<typeof schema>
interface Props { app: PushApp | null; creating: boolean; onOpenChange: (open: boolean) => void; onSaved: () => void }
export function PushAppEditSheet({ app, creating, onOpenChange, onSaved }: Props) {
const form = useForm<Values>({ 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 <Sheet open={creating || app !== null} onOpenChange={onOpenChange}><SheetContent className="w-full overflow-y-auto sm:max-w-xl"><SheetHeader><SheetTitle>{app ? '编辑推送应用' : '新建推送应用'}</SheetTitle></SheetHeader><form className="grid gap-4 px-4 pb-8" onSubmit={form.handleSubmit(submit)}><Field label="应用标识" error={form.formState.errors.app_key?.message}><Input {...form.register('app_key')} /></Field><Field label="推送通道"><Controller control={form.control} name="provider" render={({ field }) => <Select value={field.value} onValueChange={field.onChange}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="apn">APNs</SelectItem><SelectItem value="fcm">FCM</SelectItem><SelectItem value="hms">HMS</SelectItem></SelectContent></Select>} /></Field><Field label="显示名称"><Input {...form.register('label')} /></Field><Field label="Topic / Bundle ID"><Input {...form.register('topic')} /></Field><Field label="私密凭证文件名"><Input placeholder="仅填写 storage 私密目录中的文件名" {...form.register('credentials_path')} /></Field>{provider === 'apn' ? <><Field label="Key ID"><Input {...form.register('key_id')} /></Field><Field label="Team ID"><Input {...form.register('team_id')} /></Field><Check control={form.control} name="production" label="生产 APNs 环境" /></> : <Field label="Project ID"><Input {...form.register('project_id')} /></Field>}<Check control={form.control} name="is_active" label="启用此配置" /><Button type="submit" disabled={form.formState.isSubmitting}>{form.formState.isSubmitting ? '保存中…' : '保存'}</Button></form></SheetContent></Sheet>
}
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<PushProvider, 'mipush'>, 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 <div className="grid gap-2"><Label>{label}</Label>{children}{error && <p className="text-sm text-destructive">{error}</p>}</div> }
function Check({ control, name, label }: { control: ReturnType<typeof useForm<Values>>['control']; name: 'production' | 'is_active'; label: string }) { return <Controller control={control} name={name} render={({ field }) => <label className="flex items-center gap-2 text-sm"><Checkbox checked={field.value} onCheckedChange={(value) => field.onChange(value === true)} />{label}</label>} /> }
+21
View File
@@ -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,
+38
View File
@@ -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<PushApp | null>(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<PushApp>[] = [
{ header: '应用', cell: ({ row }) => <div><div className="font-medium">{row.original.label ?? row.original.app_key}</div><code className="text-xs text-muted-foreground">{row.original.app_key}</code></div> },
{ header: '通道', cell: ({ row }) => <Badge variant="secondary">{row.original.provider.toUpperCase()}</Badge> },
{ header: '平台', accessorKey: 'platform' },
{ header: '凭证', cell: ({ row }) => row.original.credentials_present ? <Badge className="bg-emerald-500/12 text-emerald-700"></Badge> : <Badge variant="destructive"></Badge> },
{ header: '状态', cell: ({ row }) => row.original.is_active ? '启用' : '停用' },
{ header: '操作', cell: ({ row }) => <div className="flex gap-1.5">{canManage && <Button size="sm" variant="ghost" onClick={() => void verify(row.original)}></Button>}{canManage && row.original.provider !== 'mipush' && <Button size="sm" variant="outline" onClick={() => setEditing(row.original)}></Button>}{canManage && <ConfirmDialog trigger={<Button size="sm" variant="destructive"></Button>} title={`删除 ${row.original.app_key}/${row.original.provider}`} confirmLabel="删除" destructive onConfirm={() => remove(row.original)} />}</div> },
]
return <div><PageHeader title="推送应用" description="管理每个应用和推送通道的凭证与路由" actions={canManage ? <Button onClick={() => setCreating(true)}></Button> : undefined} /><PushAppEditSheet app={editing} creating={creating} onOpenChange={(open) => { if (!open) { setEditing(null); setCreating(false) } }} onSaved={refresh} /><DataTable columns={columns} data={query.data?.data ?? []} loading={query.isLoading} /></div>
}