feat(notification): manage push applications
This commit is contained in:
@@ -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`) }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>} /> }
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
Reference in New Issue
Block a user