fix: M3 audit — scheduleState persistence, AI→ReviewCard subscriber, ActiveRecall queue, streak bug, domain events, admin pages
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 8s
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 8s
- M3-02: Add scheduleState to ReviewCard Prisma model + persist in updateCard/insertCard - M3-02: Add ReviewCardSubscriber (OnEvent 'ai.analysis.completed' → generateCards) - M3-02: Add AdminReviewController (GET /admin-api/reviews) - M3-01: ActiveRecall now enqueues via AiAnalysisService instead of direct workflow call - M3-01: FocusItem model adds source field, worker uses status:'open' - M3-03: Fix streak calculation (break on gap), add StreakUpdatedEvent - M3-03: Add LearningGoal/StreakRecord/LearningStats to Prisma - M3-03: Fix FocusItem recommendation query (status:'pending' → 'open') - Admin pages: ReviewAdmin, NotificationAdmin, CacheAdmin Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
03c38f5d4c
commit
9d080bf9e8
@ -38,6 +38,9 @@ const Placeholder = lazy(() => import('./pages/Placeholder'))
|
||||
const ForbiddenPage = lazy(() => import('./pages/403'))
|
||||
const NotFoundPage = lazy(() => import('./pages/404'))
|
||||
const ServerErrorPage = lazy(() => import('./pages/500'))
|
||||
const ReviewAdmin = lazy(() => import('./pages/ReviewAdmin'))
|
||||
const NotificationAdmin = lazy(() => import('./pages/NotificationAdmin'))
|
||||
const CacheAdmin = lazy(() => import('./pages/CacheAdmin'))
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
@ -152,6 +155,10 @@ function App() {
|
||||
path="vector"
|
||||
element={<PermissionGuard requiredRole="SUPER_ADMIN"><Suspense fallback={<PageLoading />}><VectorAdminPage /></Suspense></PermissionGuard>}
|
||||
/>
|
||||
<Route
|
||||
path="cache"
|
||||
element={<PermissionGuard requiredRole="SUPER_ADMIN"><Suspense fallback={<PageLoading />}><CacheAdmin /></Suspense></PermissionGuard>}
|
||||
/>
|
||||
<Route
|
||||
path="servers"
|
||||
element={
|
||||
@ -168,6 +175,8 @@ function App() {
|
||||
</PermissionGuard>
|
||||
}
|
||||
/>
|
||||
<Route path="reviews" element={<Suspense fallback={<PageLoading />}><ReviewAdmin /></Suspense>} />
|
||||
<Route path="notification-admin" element={<Suspense fallback={<PageLoading />}><NotificationAdmin /></Suspense>} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@ -42,8 +42,10 @@ export const adminMenuItems: AdminMenuItem[] = [
|
||||
{ path: '/vector', name: '向量检索' },
|
||||
{ path: '/config', name: '配置管理' },
|
||||
{ path: '/cache', name: '缓存管理' },
|
||||
{ path: '/notification-admin', name: '通知管理' },
|
||||
{ path: '/safety', name: '内容安全' },
|
||||
]},
|
||||
{ path: '/reviews', name: '复习数据', icon: <BookOutlined />, requiredRole: 'ADMIN' },
|
||||
{ path: '/settings', name: '系统配置', icon: <SettingOutlined />, requiredRole: 'ADMIN' },
|
||||
]
|
||||
|
||||
|
||||
102
src/pages/CacheAdmin.tsx
Normal file
102
src/pages/CacheAdmin.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import React from 'react'
|
||||
import { Card, Button, Statistic, Row, Col, Space, Input, Typography, message } from 'antd'
|
||||
import { ClearOutlined, ReloadOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
export default function CacheAdmin() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: stats, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'cache-stats'],
|
||||
queryFn: () => api.get('/admin-api/cache/stats').then(r => r.data?.data),
|
||||
refetchInterval: 10_000,
|
||||
})
|
||||
|
||||
const flushModule = useMutation({
|
||||
mutationFn: (module: string) => api.post(`/admin-api/cache/flush/${module}`),
|
||||
onSuccess: (_, mod) => {
|
||||
message.success(`已清除 ${mod} 模块缓存`)
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'cache-stats'] })
|
||||
},
|
||||
})
|
||||
|
||||
const flushAll = useMutation({
|
||||
mutationFn: () => api.post('/admin-api/cache/flush-all'),
|
||||
onSuccess: () => {
|
||||
message.success('已清除所有缓存')
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'cache-stats'] })
|
||||
},
|
||||
})
|
||||
|
||||
const resetStats = useMutation({
|
||||
mutationFn: () => api.post('/admin-api/cache/reset-stats'),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'cache-stats'] }),
|
||||
})
|
||||
|
||||
const modules = [
|
||||
{ key: 'config', label: '配置缓存' },
|
||||
{ key: 'workspace', label: '工作台缓存' },
|
||||
{ key: 'admin', label: 'Admin 缓存' },
|
||||
{ key: 'ai', label: 'AI 路由缓存' },
|
||||
{ key: 'safety', label: '安全词库缓存' },
|
||||
{ key: 'user', label: '用户信息缓存' },
|
||||
{ key: 'kb', label: '知识库缓存' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={4}>缓存管理</Title>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="命中次数" value={stats?.hits ?? 0} loading={isLoading} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="未命中次数" value={stats?.misses ?? 0} loading={isLoading} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="命中率" value={stats?.hitRate ?? 0} suffix="%" precision={1} loading={isLoading} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="Redis 状态" value={stats?.available ? '在线' : '离线'} valueStyle={{ color: stats?.available ? '#52c41a' : '#f5222d' }} loading={isLoading} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="按模块清除缓存" style={{ marginBottom: 24 }}>
|
||||
<Space wrap>
|
||||
{modules.map(m => (
|
||||
<Button
|
||||
key={m.key}
|
||||
icon={<ClearOutlined />}
|
||||
onClick={() => flushModule.mutate(m.key)}
|
||||
loading={flushModule.isPending}
|
||||
>
|
||||
{m.label}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Space>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => flushAll.mutate()}
|
||||
loading={flushAll.isPending}
|
||||
>
|
||||
清除所有缓存
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => resetStats.mutate()}
|
||||
loading={resetStats.isPending}
|
||||
>
|
||||
重置统计
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
155
src/pages/NotificationAdmin.tsx
Normal file
155
src/pages/NotificationAdmin.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Card, Table, Button, Modal, Form, Input, Select, Tag, Space, Typography, message } from 'antd'
|
||||
import { PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
const channelColors: Record<string, string> = {
|
||||
in_app: 'blue', push: 'green', email: 'orange',
|
||||
}
|
||||
|
||||
export default function NotificationAdmin() {
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<any>(null)
|
||||
const [form] = Form.useForm()
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: templates, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'notification-templates'],
|
||||
queryFn: () => api.get('/admin-api/notifications/templates').then(r => r.data?.data ?? []),
|
||||
})
|
||||
|
||||
const { data: sendLogs } = useQuery({
|
||||
queryKey: ['admin', 'notification-logs'],
|
||||
queryFn: () => api.get('/admin-api/notifications/send-log?limit=50').then(r => r.data?.data ?? []),
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (values: any) =>
|
||||
editing
|
||||
? api.patch(`/admin-api/notifications/templates/${editing.id}`, values)
|
||||
: api.post('/admin-api/notifications/templates', values),
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '模板已更新' : '模板已创建')
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'notification-templates'] })
|
||||
setModalOpen(false)
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/admin-api/notifications/templates/${id}`),
|
||||
onSuccess: () => {
|
||||
message.success('模板已删除')
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'notification-templates'] })
|
||||
},
|
||||
})
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (record: any) => {
|
||||
setEditing(record)
|
||||
form.setFieldsValue(record)
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const templateColumns = [
|
||||
{ title: '名称', dataIndex: 'name', width: 120 },
|
||||
{ title: '类型', dataIndex: 'type', width: 120 },
|
||||
{ title: '标题', dataIndex: 'title', width: 200, ellipsis: true },
|
||||
{
|
||||
title: '渠道', dataIndex: 'channel', width: 80,
|
||||
render: (c: string) => <Tag color={channelColors[c] || 'default'}>{c}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'enabled', width: 60,
|
||||
render: (e: boolean) => <Tag color={e ? 'green' : 'red'}>{e ? '启用' : '禁用'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作', width: 100,
|
||||
render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(record)} />
|
||||
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => deleteMutation.mutate(record.id)} />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const logColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 100, ellipsis: true },
|
||||
{ title: '用户', dataIndex: 'userId', width: 100, ellipsis: true },
|
||||
{ title: '类型', dataIndex: 'type', width: 100 },
|
||||
{ title: '标题', dataIndex: 'title', width: 200, ellipsis: true },
|
||||
{
|
||||
title: '时间', dataIndex: 'createdAt', width: 120,
|
||||
render: (d: string) => new Date(d).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '已读', dataIndex: 'readAt', width: 80,
|
||||
render: (d: string | null) => d ? '是' : '否',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={4}>通知管理</Title>
|
||||
|
||||
<Card title="通知模板" extra={<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新建模板</Button>} style={{ marginBottom: 24 }}>
|
||||
<Table
|
||||
dataSource={templates || []}
|
||||
columns={templateColumns}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="发送日志(最近 50 条)">
|
||||
<Table
|
||||
dataSource={sendLogs || []}
|
||||
columns={logColumns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑模板' : '新建模板'}
|
||||
open={modalOpen}
|
||||
onCancel={() => { setModalOpen(false); setEditing(null) }}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={saveMutation.isPending}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={saveMutation.mutate}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input placeholder="复习提醒" />
|
||||
</Form.Item>
|
||||
<Form.Item name="type" label="类型" rules={[{ required: true }]}>
|
||||
<Input placeholder="review_reminder" />
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
|
||||
<Input placeholder="通知标题" />
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="内容" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={3} placeholder="通知内容,支持 {count} 等变量" />
|
||||
</Form.Item>
|
||||
<Form.Item name="channel" label="渠道" initialValue="in_app">
|
||||
<Select options={[
|
||||
{ label: '站内消息', value: 'in_app' },
|
||||
{ label: 'Push 推送', value: 'push' },
|
||||
{ label: '邮件', value: 'email' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
src/pages/ReviewAdmin.tsx
Normal file
82
src/pages/ReviewAdmin.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Card, Table, Tag, Space, Input, Select, Typography } from 'antd'
|
||||
import { SearchOutlined } from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
active: 'blue', suspended: 'orange', completed: 'green',
|
||||
}
|
||||
|
||||
export default function ReviewAdmin() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>()
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'reviews', search, statusFilter],
|
||||
queryFn: () => api.get('/admin-api/reviews', { params: { search, status: statusFilter } }).then(r => r.data?.data ?? { items: [], total: 0 }),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 100, ellipsis: true },
|
||||
{ title: '用户', dataIndex: 'userId', width: 100, ellipsis: true },
|
||||
{ title: '正面', dataIndex: 'frontText', width: 200, ellipsis: true },
|
||||
{ title: '难度', dataIndex: 'difficulty', width: 80 },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', width: 80,
|
||||
render: (s: string) => <Tag color={statusColors[s] || 'default'}>{s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '调度', dataIndex: 'scheduleState', width: 80,
|
||||
render: (s: string) => s || '-',
|
||||
},
|
||||
{ title: '间隔(天)', dataIndex: 'intervalDays', width: 80 },
|
||||
{ title: '复习次数', dataIndex: 'repetitionCount', width: 80 },
|
||||
{ title: '失误次数', dataIndex: 'lapseCount', width: 80 },
|
||||
{
|
||||
title: '下次复习', dataIndex: 'nextReviewAt', width: 120,
|
||||
render: (d: string) => d ? new Date(d).toLocaleDateString() : '-',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={4}>复习数据管理</Title>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Input
|
||||
placeholder="搜索卡片内容"
|
||||
prefix={<SearchOutlined />}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
style={{ width: 240 }}
|
||||
allowClear
|
||||
/>
|
||||
<Select
|
||||
placeholder="状态筛选"
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ label: '全部', value: undefined },
|
||||
{ label: 'Active', value: 'active' },
|
||||
{ label: 'Suspended', value: 'suspended' },
|
||||
{ label: 'Completed', value: 'completed' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
<Table
|
||||
dataSource={data?.items || []}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
pagination={{ total: data?.total || 0 }}
|
||||
size="small"
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user