feat(frontend): Notifications + Evolution 真实数据页接用户端 API (#1008/#1009)
This commit is contained in:
1 parent
8f30a588fb
commit
7b8c3ba2dd
5 files changed
+1009
-32
No files matched your search
@@ -11,6 +11,8 @@ import { AppServices } from '@/pages/front/AppServices';
|
||||
import { DashboardPage } from '@/pages/front/DashboardPage';
|
||||
import { ProfilePage } from '@/pages/front/ProfilePage';
|
||||
import { MarketPage } from '@/pages/front/MarketPage';
|
||||
import { NotificationsPage } from '@/pages/front/NotificationsPage';
|
||||
import { EvolutionPage } from '@/pages/front/EvolutionPage';
|
||||
import { ComingSoon } from '@/components/common/ComingSoon';
|
||||
import {
|
||||
Bot,
|
||||
@@ -18,8 +20,6 @@ import {
|
||||
Workflow,
|
||||
Shield,
|
||||
Radio,
|
||||
Bell,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
|
||||
function App() {
|
||||
@@ -113,36 +113,8 @@ function App() {
|
||||
/>
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
<Route
|
||||
path="notifications"
|
||||
element={
|
||||
<ComingSoon
|
||||
title="消息通知"
|
||||
icon={Bell}
|
||||
description="集中查看系统通知与任务动态。"
|
||||
features={[
|
||||
'任务完成 / 失败提醒',
|
||||
'配额与账单告警',
|
||||
'按渠道订阅推送',
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="evolution"
|
||||
element={
|
||||
<ComingSoon
|
||||
title="自进化"
|
||||
icon={Sparkles}
|
||||
description="让平台从使用中持续学习、自我优化能力。"
|
||||
features={[
|
||||
'对话与工具调用沉淀',
|
||||
'技能自动迭代建议',
|
||||
'知识库增量同步',
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="evolution" element={<EvolutionPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -562,3 +562,206 @@ export const tokenApi = {
|
||||
|
||||
getBalance: () => data<TokenBalance>('/token/balance'),
|
||||
};
|
||||
|
||||
// ─── Notifications 消息通知 ──────────────────────────────────────────────────────
|
||||
// 后端契约 (notifications.py):全部 {code,data,message}
|
||||
// GET / → { total, page, page_size, items:[NotificationItem] }
|
||||
// PUT /{id}/read → {code,message}; PUT /read-all?type= → {code,data:{count},message}
|
||||
// GET /unread-count → {code,data:{count},message}
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string;
|
||||
type: string | null;
|
||||
title: string;
|
||||
content: string;
|
||||
is_read: boolean;
|
||||
related_id: string | null;
|
||||
related_type: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface NotificationList {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: NotificationItem[];
|
||||
}
|
||||
|
||||
export const notificationsApi = {
|
||||
list: (params: { type?: string; is_read?: boolean; page?: number; page_size?: number } = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.type) qs.set('type', params.type);
|
||||
if (params.is_read !== undefined) qs.set('is_read', String(params.is_read));
|
||||
if (params.page) qs.set('page', String(params.page));
|
||||
if (params.page_size) qs.set('page_size', String(params.page_size));
|
||||
const q = qs.toString();
|
||||
return data<NotificationList>(`/notifications/${q ? `?${q}` : ''}`);
|
||||
},
|
||||
|
||||
markRead: (id: string) =>
|
||||
request(`/notifications/${id}/read`, { method: 'PUT' }),
|
||||
|
||||
markAllRead: (type?: string) =>
|
||||
request(`/notifications/read-all${type ? `?type=${encodeURIComponent(type)}` : ''}`, { method: 'PUT' }),
|
||||
|
||||
unreadCount: () =>
|
||||
data<{ count: number }>('/notifications/unread-count'),
|
||||
};
|
||||
|
||||
// ─── Evolution AI 自进化 ────────────────────────────────────────────────────────
|
||||
// 后端契约 (evolution.py + services/evolution_service.py):全部 {code,data,...}
|
||||
// 技能状态 state: main | staging | baby | frozen
|
||||
// 事件类型 event_type: created | edited | promoted | frozen | ...(字符串透传)
|
||||
|
||||
export type EvolutionSkillState = 'main' | 'staging' | 'baby' | 'frozen';
|
||||
|
||||
export interface EvolutionSkill {
|
||||
id: string;
|
||||
skill_name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
category: string;
|
||||
state: EvolutionSkillState;
|
||||
use_count: number;
|
||||
version: number;
|
||||
last_used_at: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
skill_md_content?: string | null; // 仅 detail 返回
|
||||
}
|
||||
|
||||
export interface EvolutionEvent {
|
||||
id: string;
|
||||
skill_name: string;
|
||||
event_type: string;
|
||||
description: string;
|
||||
version_before: number | null;
|
||||
version_after: number | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface EvolutionTrajectory {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
file_path: string | null;
|
||||
status: string;
|
||||
atom_count: number | null;
|
||||
token_used: number | null;
|
||||
created_at: string | null;
|
||||
processed_at: string | null;
|
||||
weightscore?: number;
|
||||
atom_id?: string | null;
|
||||
}
|
||||
|
||||
export interface EvolutionSkillTree {
|
||||
categories: { name: string; skills: EvolutionSkill[] }[];
|
||||
total_count: number;
|
||||
main_count: number;
|
||||
baby_count: number;
|
||||
staging_count: number;
|
||||
frozen_count: number;
|
||||
}
|
||||
|
||||
export interface EvolutionStatistics {
|
||||
ranking: { skill_name: string; category: string; use_count: number; state: string; last_used_at: string | null }[];
|
||||
total_use_count: number;
|
||||
category_distribution: { category: string; count: number }[];
|
||||
daily_trend: { date: string; count: number }[];
|
||||
low_use_skills: EvolutionSkill[];
|
||||
}
|
||||
|
||||
export interface EvolutionDimension {
|
||||
label: string;
|
||||
value: number;
|
||||
maxValue: number;
|
||||
}
|
||||
|
||||
export interface EvolutionProfile {
|
||||
level: number;
|
||||
level_title: string;
|
||||
total_skills: number;
|
||||
active_skills: number;
|
||||
staging_skills: number;
|
||||
baby_skills: number;
|
||||
total_use_count: number;
|
||||
dimensions: EvolutionDimension[];
|
||||
tags: string[];
|
||||
growth_suggestions: string[];
|
||||
}
|
||||
|
||||
export interface EvolutionPipelineStatus {
|
||||
scheduler_running: boolean;
|
||||
watcher_status: string;
|
||||
pipeline_status: string;
|
||||
discovered_count: number;
|
||||
done_count: number;
|
||||
total_skills: number;
|
||||
total_trajectories: number;
|
||||
}
|
||||
|
||||
export interface EvolutionWatcherStatus {
|
||||
user_id: string;
|
||||
scheduler_running: boolean;
|
||||
watcher_status: string;
|
||||
pipeline_status: string;
|
||||
pipeline_processed: number;
|
||||
watcher_error: string | null;
|
||||
skill_count: number;
|
||||
trajectory_count: number;
|
||||
}
|
||||
|
||||
export interface EvolutionReport {
|
||||
id: string;
|
||||
report_type: string;
|
||||
period_start: string | null;
|
||||
period_end: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface EvolutionReportDetail extends EvolutionReport {
|
||||
user_id: string;
|
||||
report_data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const evolutionApi = {
|
||||
// 技能管理
|
||||
listSkills: (params: { state?: string; keyword?: string; sort_by?: string; order?: string } = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.state) qs.set('state', params.state);
|
||||
if (params.keyword) qs.set('keyword', params.keyword);
|
||||
if (params.sort_by) qs.set('sort_by', params.sort_by);
|
||||
if (params.order) qs.set('order', params.order);
|
||||
const q = qs.toString();
|
||||
return data<EvolutionSkill[]>(`/evolution/skills${q ? `?${q}` : ''}`);
|
||||
},
|
||||
|
||||
getSkillDetail: (skillName: string) =>
|
||||
data<EvolutionSkill>(`/evolution/skills/${encodeURIComponent(skillName)}`),
|
||||
|
||||
// 进化事件
|
||||
listEvents: (params: { event_type?: string; skill_name?: string; limit?: number } = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.event_type) qs.set('event_type', params.event_type);
|
||||
if (params.skill_name) qs.set('skill_name', params.skill_name);
|
||||
if (params.limit) qs.set('limit', String(params.limit));
|
||||
const q = qs.toString();
|
||||
return data<EvolutionEvent[]>(`/evolution/events${q ? `?${q}` : ''}`);
|
||||
},
|
||||
|
||||
// 驾驶舱
|
||||
getSkillTree: () => data<EvolutionSkillTree>('/evolution/dashboard/skill-tree'),
|
||||
getTimeline: (event_type?: string) =>
|
||||
data<EvolutionEvent[]>(`/evolution/dashboard/timeline${event_type ? `?event_type=${encodeURIComponent(event_type)}` : ''}`),
|
||||
getStatistics: () => data<EvolutionStatistics>('/evolution/dashboard/statistics'),
|
||||
getProfile: () => data<EvolutionProfile>('/evolution/dashboard/profile'),
|
||||
|
||||
// 流水线 / Watcher
|
||||
getPipelineStatus: () => data<EvolutionPipelineStatus>('/evolution/pipeline/status'),
|
||||
getWatcherStatus: () => data<EvolutionWatcherStatus>('/evolution/watcher/status'),
|
||||
|
||||
// 报告
|
||||
listReports: (report_type?: string) =>
|
||||
data<EvolutionReport[]>(`/evolution/reports${report_type ? `?report_type=${encodeURIComponent(report_type)}` : ''}`),
|
||||
getReportDetail: (reportId: string) =>
|
||||
data<EvolutionReportDetail>(`/evolution/reports/${reportId}`),
|
||||
};
|
||||
@@ -7,12 +7,17 @@ import {
|
||||
imBotApi,
|
||||
profileApi,
|
||||
tokenApi,
|
||||
notificationsApi,
|
||||
evolutionApi,
|
||||
type TokenOverview,
|
||||
type MyApp,
|
||||
type MarketAd,
|
||||
type MarketSort,
|
||||
type MarketAppType,
|
||||
type MarketAppSource,
|
||||
type EvolutionSkill,
|
||||
type EvolutionEvent,
|
||||
type EvolutionReport,
|
||||
} from '@/api/client';
|
||||
|
||||
// ─── Auth ───────────────────────────────────────────────────────────────────────
|
||||
@@ -372,3 +377,120 @@ export function useTokenLogs(params: {
|
||||
placeholderData: (prev) => prev, // 翻页时保留旧数据,避免闪烁
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Notifications 消息通知 ─────────────────────────────────────────────────────
|
||||
|
||||
export function useNotifications(params: {
|
||||
type?: string;
|
||||
is_read?: boolean;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
} = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['notifications', 'list', params],
|
||||
queryFn: () => notificationsApi.list(params),
|
||||
placeholderData: (prev) => prev, // 翻页/筛选保留旧数据,避免闪烁
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnreadCount() {
|
||||
return useQuery({
|
||||
queryKey: ['notifications', 'unread-count'],
|
||||
queryFn: notificationsApi.unreadCount,
|
||||
refetchInterval: 60000, // 每分钟刷新未读计数
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkNotificationRead() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => notificationsApi.markRead(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['notifications', 'list'] });
|
||||
qc.invalidateQueries({ queryKey: ['notifications', 'unread-count'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkAllNotificationsRead() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (type?: string) => notificationsApi.markAllRead(type),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['notifications', 'list'] });
|
||||
qc.invalidateQueries({ queryKey: ['notifications', 'unread-count'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Evolution AI 自进化 ────────────────────────────────────────────────────────
|
||||
|
||||
export function useEvolutionSkills(params: {
|
||||
state?: string;
|
||||
keyword?: string;
|
||||
sort_by?: string;
|
||||
order?: string;
|
||||
} = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'skills', params],
|
||||
queryFn: () => evolutionApi.listSkills(params),
|
||||
select: (d: EvolutionSkill[]) => (Array.isArray(d) ? d : []),
|
||||
});
|
||||
}
|
||||
|
||||
export function useEvolutionEvents(params: {
|
||||
event_type?: string;
|
||||
skill_name?: string;
|
||||
limit?: number;
|
||||
} = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'events', params],
|
||||
queryFn: () => evolutionApi.listEvents(params),
|
||||
select: (d: EvolutionEvent[]) => (Array.isArray(d) ? d : []),
|
||||
});
|
||||
}
|
||||
|
||||
export function useEvolutionSkillTree() {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'skill-tree'],
|
||||
queryFn: evolutionApi.getSkillTree,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEvolutionStatistics() {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'statistics'],
|
||||
queryFn: evolutionApi.getStatistics,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEvolutionProfile() {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'profile'],
|
||||
queryFn: evolutionApi.getProfile,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEvolutionPipelineStatus() {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'pipeline-status'],
|
||||
queryFn: evolutionApi.getPipelineStatus,
|
||||
refetchInterval: 30000, // 流水线状态每 30s 轮询
|
||||
});
|
||||
}
|
||||
|
||||
export function useEvolutionWatcherStatus() {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'watcher-status'],
|
||||
queryFn: evolutionApi.getWatcherStatus,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEvolutionReports(report_type?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'reports', report_type],
|
||||
queryFn: () => evolutionApi.listReports(report_type),
|
||||
select: (d: EvolutionReport[]) => (Array.isArray(d) ? d : []),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Sparkles,
|
||||
Brain,
|
||||
Network,
|
||||
BarChart3,
|
||||
Activity,
|
||||
GitBranch,
|
||||
RefreshCw,
|
||||
Cpu,
|
||||
ArrowUpRight,
|
||||
Loader2,
|
||||
TrendingUp,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useEvolutionProfile,
|
||||
useEvolutionSkills,
|
||||
useEvolutionStatistics,
|
||||
useEvolutionPipelineStatus,
|
||||
useEvolutionEvents,
|
||||
} from '@/hooks/useApi';
|
||||
import type {
|
||||
EvolutionSkill,
|
||||
EvolutionSkillState,
|
||||
EvolutionStatistics,
|
||||
EvolutionProfile,
|
||||
EvolutionPipelineStatus,
|
||||
EvolutionEvent,
|
||||
} from '@/api/client';
|
||||
|
||||
// 技能状态 → 中文标签 + 颜色
|
||||
const STATE_LABELS: Record<EvolutionSkillState, string> = {
|
||||
main: '成熟',
|
||||
staging: '灰度',
|
||||
baby: '萌芽',
|
||||
frozen: '冻结',
|
||||
};
|
||||
|
||||
const STATE_BADGE: Record<EvolutionSkillState, 'default' | 'secondary' | 'outline' | 'success'> = {
|
||||
main: 'success',
|
||||
staging: 'default',
|
||||
baby: 'secondary',
|
||||
frozen: 'outline',
|
||||
};
|
||||
|
||||
function formatTime(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// 雷达/维度条
|
||||
function DimensionBars({ dimensions }: { dimensions: EvolutionProfile['dimensions'] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{dimensions.map((dim) => {
|
||||
const pct = Math.round((dim.value / dim.maxValue) * 100);
|
||||
return (
|
||||
<div key={dim.label}>
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-muted-foreground">{dim.label}</span>
|
||||
<span className="font-medium text-foreground">{dim.value.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 能力画像卡
|
||||
function ProfileCard({ profile }: { profile: EvolutionProfile }) {
|
||||
return (
|
||||
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-amber-50/40 dark:to-amber-950/20">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Sparkles size={30} className="text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-xl font-bold text-foreground">
|
||||
Lv.{profile.level} {profile.level_title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
共 {profile.total_skills} 个技能 · 累计调用 {profile.total_use_count} 次
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-5" />
|
||||
|
||||
<DimensionBars dimensions={profile.dimensions} />
|
||||
|
||||
{profile.growth_suggestions.length > 0 && (
|
||||
<div className="mt-5 space-y-2">
|
||||
<p className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
||||
<TrendingUp size={15} className="text-primary" />
|
||||
成长建议
|
||||
</p>
|
||||
{profile.growth_suggestions.map((s, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
<ArrowUpRight size={14} className="text-primary mt-0.5 shrink-0" />
|
||||
<span>{s}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profile.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-4">
|
||||
{profile.tags.map((t) => (
|
||||
<Badge key={t} variant="outline" className="text-xs">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 技能网格
|
||||
function SkillCard({ skill }: { skill: EvolutionSkill }) {
|
||||
return (
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Cpu size={17} className="text-primary" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-foreground truncate">{skill.skill_name}</h4>
|
||||
</div>
|
||||
<Badge variant={STATE_BADGE[skill.state] ?? 'outline'} className="shrink-0">
|
||||
{STATE_LABELS[skill.state] ?? skill.state}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mb-3 min-h-[2rem]">
|
||||
{skill.description || '暂无描述'}
|
||||
</p>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>调用 {skill.use_count}</span>
|
||||
<span>v{skill.version}</span>
|
||||
</div>
|
||||
{skill.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{skill.tags.slice(0, 3).map((t) => (
|
||||
<Badge key={t} variant="outline" className="text-[10px] py-0">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 流水线状态条
|
||||
function PipelineStatusCard({ status }: { status: EvolutionPipelineStatus }) {
|
||||
const running = status.scheduler_running;
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Activity size={18} className="text-primary" />
|
||||
<h3 className="font-semibold text-foreground">进化流水线</h3>
|
||||
<Badge
|
||||
variant={running ? 'success' : 'outline'}
|
||||
className="ml-auto gap-1"
|
||||
>
|
||||
<span
|
||||
className={
|
||||
'w-1.5 h-1.5 rounded-full ' +
|
||||
(running ? 'bg-green-500 animate-pulse' : 'bg-muted-foreground')
|
||||
}
|
||||
/>
|
||||
{running ? '运行中' : '未运行'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-center">
|
||||
<Stat label="待处理轨迹" value={status.discovered_count} />
|
||||
<Stat label="已完成轨迹" value={status.done_count} />
|
||||
<Stat label="技能总数" value={status.total_skills} />
|
||||
<Stat label="轨迹总数" value={status.total_trajectories} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
流水线状态:{status.pipeline_status} · 引擎:{status.watcher_status}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-muted/50 py-3">
|
||||
<p className="text-xl font-bold text-foreground">{value}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 统计卡(Top 技能 + 类别分布)
|
||||
function StatisticsCard({ stats }: { stats: EvolutionStatistics }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<BarChart3 size={18} className="text-primary" />
|
||||
<h3 className="font-semibold text-foreground">使用统计</h3>
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
总调用 {stats.total_use_count}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Top 技能 */}
|
||||
<p className="text-sm font-medium text-foreground mb-2 flex items-center gap-1.5">
|
||||
<TrendingUp size={14} className="text-primary" />
|
||||
热门技能 Top {stats.ranking.length}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{stats.ranking.slice(0, 5).map((r, i) => {
|
||||
const max = stats.ranking[0]?.use_count || 1;
|
||||
const pct = Math.round((r.use_count / max) * 100);
|
||||
return (
|
||||
<div key={r.skill_name}>
|
||||
<div className="flex items-center justify-between text-xs mb-0.5">
|
||||
<span className="text-muted-foreground truncate">
|
||||
{i + 1}. {r.skill_name}
|
||||
</span>
|
||||
<span className="font-medium text-foreground shrink-0 ml-2">
|
||||
{r.use_count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary/70"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{stats.ranking.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">暂无使用数据</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
{/* 类别分布 */}
|
||||
<p className="text-sm font-medium text-foreground mb-2 flex items-center gap-1.5">
|
||||
<Layers size={14} className="text-primary" />
|
||||
类别分布
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats.category_distribution.map((c) => (
|
||||
<Badge key={c.category} variant="secondary" className="gap-1">
|
||||
{c.category}
|
||||
<span className="text-primary font-medium">{c.count}</span>
|
||||
</Badge>
|
||||
))}
|
||||
{stats.category_distribution.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">暂无分布数据</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 事件流
|
||||
function EventFeed({ events }: { events: EvolutionEvent[] }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<GitBranch size={18} className="text-primary" />
|
||||
<h3 className="font-semibold text-foreground">进化事件流</h3>
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无进化事件</p>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-80 overflow-y-auto">
|
||||
{events.map((e) => (
|
||||
<div key={e.id} className="flex items-start gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-primary mt-2 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-foreground">{e.description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{e.skill_name}
|
||||
{e.version_after != null && ` · v${e.version_after}`} ·{' '}
|
||||
{formatTime(e.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0 text-[10px]">
|
||||
{e.event_type}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function EvolutionPage() {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const { data: profile, isLoading: pLoading } = useEvolutionProfile();
|
||||
const { data: skills, isLoading: sLoading } = useEvolutionSkills();
|
||||
const { data: stats } = useEvolutionStatistics();
|
||||
const { data: pipeline } = useEvolutionPipelineStatus();
|
||||
const { data: events } = useEvolutionEvents({ limit: 30 });
|
||||
|
||||
const skillList: EvolutionSkill[] = Array.isArray(skills) ? skills : [];
|
||||
// 按 state 分组
|
||||
const grouped = useGroupedSkills(skillList);
|
||||
const isLoading = pLoading || sLoading;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Brain size={20} className="text-primary" />
|
||||
AI 自进化
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
平台从对话与工具调用中持续学习,自动沉淀与迭代你的专属技能
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
onClick={() => setRefreshKey((k) => k + 1)}
|
||||
>
|
||||
<RefreshCw size={15} className={refreshKey ? 'animate-spin' : ''} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 size={28} className="animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
key={refreshKey}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* 能力画像 + 流水线状态 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{profile ? (
|
||||
<ProfileCard profile={profile} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center text-muted-foreground">
|
||||
<Brain size={40} className="mx-auto mb-3 opacity-50" />
|
||||
暂无能力数据,多使用 AI 即可激活自进化
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{pipeline && <PipelineStatusCard status={pipeline} />}
|
||||
</div>
|
||||
|
||||
{/* 统计 + 事件流 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{stats && <StatisticsCard stats={stats} />}
|
||||
{events && <EventFeed events={events} />}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 技能图谱 */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Network size={18} className="text-primary" />
|
||||
<h3 className="font-semibold text-foreground">技能图谱</h3>
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{skillList.length} 个技能
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{skillList.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Network size={44} className="mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">还没有进化出技能,继续与 AI 对话吧</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{grouped.map((g) => (
|
||||
<div key={g.category}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{g.category}
|
||||
</h4>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{g.skills.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{g.skills.map((sk) => (
|
||||
<SkillCard key={sk.id} skill={sk} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 按 category 分组,保持顺序稳定
|
||||
function useGroupedSkills(skills: EvolutionSkill[]): { category: string; skills: EvolutionSkill[] }[] {
|
||||
return useMemo(() => {
|
||||
const map = new Map<string, EvolutionSkill[]>();
|
||||
for (const s of skills) {
|
||||
const cat = s.category || '未分类';
|
||||
if (!map.has(cat)) map.set(cat, []);
|
||||
map.get(cat)!.push(s);
|
||||
}
|
||||
return Array.from(map.entries()).map(([category, list]) => ({ category, skills: list }));
|
||||
}, [skills]);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Bell,
|
||||
CheckCheck,
|
||||
Check,
|
||||
Circle,
|
||||
Loader2,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useNotifications,
|
||||
useUnreadCount,
|
||||
useMarkNotificationRead,
|
||||
useMarkAllNotificationsRead,
|
||||
} from '@/hooks/useApi';
|
||||
import type { NotificationItem } from '@/api/client';
|
||||
|
||||
// 通知类型 → 中文标签(后端 type 透传,未知类型回退「通知」)
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
task: '任务',
|
||||
billing: '账单',
|
||||
system: '系统',
|
||||
evolution: '自进化',
|
||||
market: '市场',
|
||||
};
|
||||
|
||||
function typeLabel(t: string | null): string {
|
||||
if (!t) return '通知';
|
||||
return TYPE_LABELS[t] ?? t;
|
||||
}
|
||||
|
||||
function formatTime(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const min = Math.floor(diff / 60000);
|
||||
if (min < 1) return '刚刚';
|
||||
if (min < 60) return `${min} 分钟前`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr} 小时前`;
|
||||
const day = Math.floor(hr / 24);
|
||||
if (day < 7) return `${day} 天前`;
|
||||
return d.toLocaleDateString('zh-CN');
|
||||
}
|
||||
|
||||
export function NotificationsPage() {
|
||||
const [isRead, setIsRead] = useState<boolean | undefined>(undefined);
|
||||
const [page, setPage] = useState(1);
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
const { data, isLoading, isFetching } = useNotifications({
|
||||
is_read: isRead,
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
const { data: unread } = useUnreadCount();
|
||||
const markRead = useMarkNotificationRead();
|
||||
const markAllRead = useMarkAllNotificationsRead();
|
||||
|
||||
const items: NotificationItem[] = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const unreadCount = unread?.count ?? 0;
|
||||
|
||||
// 筛选 tabs:全部 / 未读 / 已读
|
||||
const filters: { key: string; label: string; value: boolean | undefined }[] = [
|
||||
{ key: 'all', label: '全部', value: undefined },
|
||||
{ key: 'unread', label: '未读', value: false },
|
||||
{ key: 'read', label: '已读', value: true },
|
||||
];
|
||||
|
||||
const busy = markRead.isPending || markAllRead.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Bell size={20} className="text-primary" />
|
||||
消息通知
|
||||
{unreadCount > 0 && (
|
||||
<Badge className="bg-primary/10 text-primary border-primary/20 ml-1">
|
||||
{unreadCount} 未读
|
||||
</Badge>
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
集中查看系统通知、任务动态与配额告警
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
disabled={unreadCount === 0 || busy}
|
||||
onClick={() => markAllRead.mutate(undefined)}
|
||||
>
|
||||
<CheckCheck size={15} />
|
||||
全部已读
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 筛选 tabs */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Filter size={15} className="text-muted-foreground" />
|
||||
{filters.map((f) => (
|
||||
<Button
|
||||
key={f.key}
|
||||
size="sm"
|
||||
variant={isRead === f.value ? 'default' : 'outline'}
|
||||
onClick={() => {
|
||||
setIsRead(f.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 列表 */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 size={28} className="animate-spin text-primary" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<Bell size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium mb-1">暂无通知</p>
|
||||
<p className="text-sm">
|
||||
{isRead === false ? '所有通知都已读啦' : '系统有动态时会在这里提醒你'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
{items.map((n: NotificationItem) => (
|
||||
<Card
|
||||
key={n.id}
|
||||
className={
|
||||
'transition-colors ' +
|
||||
(n.is_read ? 'bg-card' : 'bg-primary/5 border-primary/20')
|
||||
}
|
||||
>
|
||||
<CardContent className="p-4 flex items-start gap-3">
|
||||
{/* 已读/未读指示 */}
|
||||
<div className="mt-1 shrink-0">
|
||||
{n.is_read ? (
|
||||
<Check size={16} className="text-muted-foreground" />
|
||||
) : (
|
||||
<Circle size={10} className="fill-primary text-primary mt-1.5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{typeLabel(n.type)}
|
||||
</Badge>
|
||||
<h3 className="font-medium text-foreground truncate">
|
||||
{n.title}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground ml-auto shrink-0">
|
||||
{formatTime(n.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{n.content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!n.is_read && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="shrink-0 gap-1 text-primary hover:text-primary"
|
||||
disabled={markRead.isPending}
|
||||
onClick={() => markRead.mutate(n.id)}
|
||||
>
|
||||
<Check size={14} />
|
||||
标为已读
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-4 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page <= 1 || isFetching}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page >= totalPages || isFetching}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user