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

- 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:
WangDL 2026-05-24 16:17:00 +08:00
parent 03c38f5d4c
commit 9d080bf9e8
5 changed files with 350 additions and 0 deletions

View File

@ -38,6 +38,9 @@ const Placeholder = lazy(() => import('./pages/Placeholder'))
const ForbiddenPage = lazy(() => import('./pages/403')) const ForbiddenPage = lazy(() => import('./pages/403'))
const NotFoundPage = lazy(() => import('./pages/404')) const NotFoundPage = lazy(() => import('./pages/404'))
const ServerErrorPage = lazy(() => import('./pages/500')) 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() const queryClient = new QueryClient()
@ -152,6 +155,10 @@ function App() {
path="vector" path="vector"
element={<PermissionGuard requiredRole="SUPER_ADMIN"><Suspense fallback={<PageLoading />}><VectorAdminPage /></Suspense></PermissionGuard>} 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 <Route
path="servers" path="servers"
element={ element={
@ -168,6 +175,8 @@ function App() {
</PermissionGuard> </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 path="*" element={<NotFoundPage />} />
</Route> </Route>
</Routes> </Routes>

View File

@ -42,8 +42,10 @@ export const adminMenuItems: AdminMenuItem[] = [
{ path: '/vector', name: '向量检索' }, { path: '/vector', name: '向量检索' },
{ path: '/config', name: '配置管理' }, { path: '/config', name: '配置管理' },
{ path: '/cache', name: '缓存管理' }, { path: '/cache', name: '缓存管理' },
{ path: '/notification-admin', name: '通知管理' },
{ path: '/safety', name: '内容安全' }, { path: '/safety', name: '内容安全' },
]}, ]},
{ path: '/reviews', name: '复习数据', icon: <BookOutlined />, requiredRole: 'ADMIN' },
{ path: '/settings', name: '系统配置', icon: <SettingOutlined />, requiredRole: 'ADMIN' }, { path: '/settings', name: '系统配置', icon: <SettingOutlined />, requiredRole: 'ADMIN' },
] ]

102
src/pages/CacheAdmin.tsx Normal file
View 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>
)
}

View 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
View 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>
)
}