feat(frontend): Notifications + Evolution 真实数据页接用户端 API (#1008/#1009)

This commit is contained in:
qihuaiflow committed 2026-08-24 08:47:36 +08:00
1 parent 8f30a588fb
commit 7b8c3ba2dd
5 files changed
+1009 -32

No files matched your search

+4 -32
View File
@@ -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>
+203
View File
@@ -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}`),
};
+122
View File
@@ -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>
);
}