diff --git a/packages/admin-client/src/features/auth/application/index.ts b/packages/admin-client/src/features/auth/application/index.ts new file mode 100644 index 0000000..5d02c23 --- /dev/null +++ b/packages/admin-client/src/features/auth/application/index.ts @@ -0,0 +1 @@ +export { useAuthStore } from './useAuthStore'; diff --git a/packages/admin-client/src/features/auth/application/useAuthStore.ts b/packages/admin-client/src/features/auth/application/useAuthStore.ts new file mode 100644 index 0000000..a28f1fd --- /dev/null +++ b/packages/admin-client/src/features/auth/application/useAuthStore.ts @@ -0,0 +1,88 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { authApi, AdminInfo } from '../infrastructure/auth.api'; + +interface AuthState { + admin: AdminInfo | null; + token: string | null; + isAuthenticated: boolean; + login: (username: string, password: string) => Promise; + logout: () => void; + checkAuth: () => Promise; + hasPermission: (permission: string) => boolean; +} + +export const useAuthStore = create()( + persist( + (set, get) => ({ + admin: null, + token: null, + isAuthenticated: false, + + login: async (username: string, password: string) => { + const data = await authApi.login(username, password); + localStorage.setItem('admin_token', data.token); + set({ + admin: data.admin, + token: data.token, + isAuthenticated: true, + }); + }, + + logout: () => { + localStorage.removeItem('admin_token'); + set({ + admin: null, + token: null, + isAuthenticated: false, + }); + }, + + checkAuth: async () => { + const token = localStorage.getItem('admin_token'); + if (!token) { + set({ isAuthenticated: false, admin: null, token: null }); + return false; + } + + try { + const admin = await authApi.verify(); + set({ + admin, + token, + isAuthenticated: true, + }); + return true; + } catch { + localStorage.removeItem('admin_token'); + } + + set({ isAuthenticated: false, admin: null, token: null }); + return false; + }, + + hasPermission: (permission: string) => { + const { admin } = get(); + if (!admin) return false; + + const permissions = admin.permissions || []; + + if (permissions.includes('*')) return true; + if (permissions.includes(permission)) return true; + + const [resource] = permission.split(':'); + if (permissions.includes(`${resource}:*`)) return true; + + return false; + }, + }), + { + name: 'auth-storage', + partialize: (state) => ({ + admin: state.admin, + token: state.token, + isAuthenticated: state.isAuthenticated, + }), + } + ) +); diff --git a/packages/admin-client/src/features/auth/infrastructure/auth.api.ts b/packages/admin-client/src/features/auth/infrastructure/auth.api.ts new file mode 100644 index 0000000..708f87d --- /dev/null +++ b/packages/admin-client/src/features/auth/infrastructure/auth.api.ts @@ -0,0 +1,26 @@ +import api from '../../../shared/utils/api'; + +export interface AdminInfo { + id: string; + username: string; + name: string; + role: string; + permissions: string[]; +} + +export interface LoginResponse { + admin: AdminInfo; + token: string; +} + +export const authApi = { + login: async (username: string, password: string): Promise => { + const response = await api.post('/admin/login', { username, password }); + return response.data.data; + }, + + verify: async (): Promise => { + const response = await api.get('/admin/verify'); + return response.data.data; + }, +}; diff --git a/packages/admin-client/src/features/auth/infrastructure/index.ts b/packages/admin-client/src/features/auth/infrastructure/index.ts new file mode 100644 index 0000000..87094c7 --- /dev/null +++ b/packages/admin-client/src/features/auth/infrastructure/index.ts @@ -0,0 +1,2 @@ +export { authApi } from './auth.api'; +export type { AdminInfo, LoginResponse } from './auth.api'; diff --git a/packages/admin-client/src/features/auth/presentation/pages/LoginPage.tsx b/packages/admin-client/src/features/auth/presentation/pages/LoginPage.tsx index be6f949..a8c8671 100644 --- a/packages/admin-client/src/features/auth/presentation/pages/LoginPage.tsx +++ b/packages/admin-client/src/features/auth/presentation/pages/LoginPage.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Form, Input, Button, Card, message } from 'antd'; import { UserOutlined, LockOutlined } from '@ant-design/icons'; -import { useAuth } from '../../../../shared/hooks/useAuth'; +import { useAuthStore } from '../../application'; interface LoginFormValues { username: string; @@ -12,7 +12,7 @@ interface LoginFormValues { export function LoginPage() { const [loading, setLoading] = useState(false); const navigate = useNavigate(); - const login = useAuth((state) => state.login); + const login = useAuthStore((state) => state.login); const onFinish = async (values: LoginFormValues) => { setLoading(true); diff --git a/packages/admin-client/src/features/dashboard/application/index.ts b/packages/admin-client/src/features/dashboard/application/index.ts new file mode 100644 index 0000000..6f968ae --- /dev/null +++ b/packages/admin-client/src/features/dashboard/application/index.ts @@ -0,0 +1,6 @@ +export { + useEvolutionStatistics, + useSystemHealth, + EVOLUTION_STATS_KEY, + SYSTEM_HEALTH_KEY, +} from './useDashboard'; diff --git a/packages/admin-client/src/features/dashboard/application/useDashboard.ts b/packages/admin-client/src/features/dashboard/application/useDashboard.ts new file mode 100644 index 0000000..11bd0e1 --- /dev/null +++ b/packages/admin-client/src/features/dashboard/application/useDashboard.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query'; +import { dashboardApi } from '../infrastructure/dashboard.api'; + +export const EVOLUTION_STATS_KEY = 'evolution-stats'; +export const SYSTEM_HEALTH_KEY = 'system-health'; + +export function useEvolutionStatistics() { + return useQuery({ + queryKey: [EVOLUTION_STATS_KEY], + queryFn: () => dashboardApi.getEvolutionStatistics(), + }); +} + +export function useSystemHealth() { + return useQuery({ + queryKey: [SYSTEM_HEALTH_KEY], + queryFn: () => dashboardApi.getSystemHealth(), + }); +} diff --git a/packages/admin-client/src/features/dashboard/infrastructure/dashboard.api.ts b/packages/admin-client/src/features/dashboard/infrastructure/dashboard.api.ts new file mode 100644 index 0000000..f551477 --- /dev/null +++ b/packages/admin-client/src/features/dashboard/infrastructure/dashboard.api.ts @@ -0,0 +1,34 @@ +import api from '../../../shared/utils/api'; + +export interface EvolutionStatistics { + totalExperiences: number; + activeExperiences: number; + pendingExperiences: number; + approvedExperiences: number; + topExperienceTypes: { type: string; count: number }[]; +} + +export interface HealthMetric { + name: string; + value: number; + threshold: number; + status: string; +} + +export interface HealthReport { + overall: string; + metrics: HealthMetric[]; + recommendations: string[]; +} + +export const dashboardApi = { + getEvolutionStatistics: async (): Promise => { + const response = await api.get('/evolution/statistics'); + return response.data.data; + }, + + getSystemHealth: async (): Promise => { + const response = await api.get('/evolution/health'); + return response.data.data; + }, +}; diff --git a/packages/admin-client/src/features/dashboard/infrastructure/index.ts b/packages/admin-client/src/features/dashboard/infrastructure/index.ts new file mode 100644 index 0000000..9289aa6 --- /dev/null +++ b/packages/admin-client/src/features/dashboard/infrastructure/index.ts @@ -0,0 +1,2 @@ +export { dashboardApi } from './dashboard.api'; +export type { EvolutionStatistics, HealthMetric, HealthReport } from './dashboard.api'; diff --git a/packages/admin-client/src/features/dashboard/presentation/pages/DashboardPage.tsx b/packages/admin-client/src/features/dashboard/presentation/pages/DashboardPage.tsx index f2bd1db..bbc8465 100644 --- a/packages/admin-client/src/features/dashboard/presentation/pages/DashboardPage.tsx +++ b/packages/admin-client/src/features/dashboard/presentation/pages/DashboardPage.tsx @@ -1,4 +1,3 @@ -import { useQuery } from '@tanstack/react-query'; import { Card, Row, Col, Statistic, Tag, Progress, List, Typography } from 'antd'; import { UserOutlined, @@ -20,7 +19,8 @@ import { Pie, Cell, } from 'recharts'; -import api from '../../../../shared/utils/api'; +import { useEvolutionStatistics, useSystemHealth } from '../../application'; +import type { HealthMetric } from '../../infrastructure'; const { Title, Text } = Typography; @@ -45,21 +45,8 @@ const mockCategoryData = [ ]; export function DashboardPage() { - const { data: evolutionStats } = useQuery({ - queryKey: ['evolution-stats'], - queryFn: async () => { - const response = await api.get('/evolution/statistics'); - return response.data.data; - }, - }); - - const { data: healthReport } = useQuery({ - queryKey: ['system-health'], - queryFn: async () => { - const response = await api.get('/evolution/health'); - return response.data.data; - }, - }); + const { data: evolutionStats } = useEvolutionStatistics(); + const { data: healthReport } = useSystemHealth(); const getHealthColor = (status: string) => { switch (status) { @@ -207,7 +194,7 @@ export function DashboardPage() { > ( + renderItem={(item: HealthMetric) => (
@@ -232,11 +219,11 @@ export function DashboardPage() { )} /> - {healthReport?.recommendations?.length > 0 && ( + {(healthReport?.recommendations?.length ?? 0) > 0 && (
建议:
    - {healthReport.recommendations.map((rec: string, i: number) => ( + {healthReport?.recommendations?.map((rec: string, i: number) => (
  • {rec}
  • ))}
diff --git a/packages/admin-client/src/features/experience/application/index.ts b/packages/admin-client/src/features/experience/application/index.ts new file mode 100644 index 0000000..6e2d170 --- /dev/null +++ b/packages/admin-client/src/features/experience/application/index.ts @@ -0,0 +1,9 @@ +export { + usePendingExperiences, + useExperienceStatistics, + useApproveExperience, + useRejectExperience, + useRunEvolution, + EXPERIENCE_QUERY_KEY, + EXPERIENCE_STATS_KEY, +} from './useExperience'; diff --git a/packages/admin-client/src/features/experience/application/useExperience.ts b/packages/admin-client/src/features/experience/application/useExperience.ts new file mode 100644 index 0000000..a9c4ecc --- /dev/null +++ b/packages/admin-client/src/features/experience/application/useExperience.ts @@ -0,0 +1,65 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { message } from 'antd'; +import { experienceApi } from '../infrastructure/experience.api'; + +export const EXPERIENCE_QUERY_KEY = 'pending-experiences'; +export const EXPERIENCE_STATS_KEY = 'experience-stats'; + +export function usePendingExperiences(type?: string, enabled: boolean = true) { + return useQuery({ + queryKey: [EXPERIENCE_QUERY_KEY, type], + queryFn: () => experienceApi.getPendingExperiences(type), + enabled, + }); +} + +export function useExperienceStatistics() { + return useQuery({ + queryKey: [EXPERIENCE_STATS_KEY], + queryFn: () => experienceApi.getStatistics(), + }); +} + +export function useApproveExperience() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, adminId }: { id: string; adminId: string }) => + experienceApi.approveExperience(id, adminId), + onSuccess: () => { + message.success('经验已批准'); + queryClient.invalidateQueries({ queryKey: [EXPERIENCE_QUERY_KEY] }); + queryClient.invalidateQueries({ queryKey: [EXPERIENCE_STATS_KEY] }); + }, + }); +} + +export function useRejectExperience() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, adminId }: { id: string; adminId: string }) => + experienceApi.rejectExperience(id, adminId), + onSuccess: () => { + message.success('经验已拒绝'); + queryClient.invalidateQueries({ queryKey: [EXPERIENCE_QUERY_KEY] }); + queryClient.invalidateQueries({ queryKey: [EXPERIENCE_STATS_KEY] }); + }, + }); +} + +export function useRunEvolution() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params?: { hoursBack?: number; limit?: number }) => + experienceApi.runEvolution(params?.hoursBack, params?.limit), + onSuccess: (result) => { + message.success( + `进化任务完成:分析了${result.conversationsAnalyzed}个对话,提取了${result.experiencesExtracted}条经验` + ); + queryClient.invalidateQueries({ queryKey: [EXPERIENCE_QUERY_KEY] }); + queryClient.invalidateQueries({ queryKey: [EXPERIENCE_STATS_KEY] }); + }, + }); +} diff --git a/packages/admin-client/src/features/experience/infrastructure/experience.api.ts b/packages/admin-client/src/features/experience/infrastructure/experience.api.ts new file mode 100644 index 0000000..ad44cce --- /dev/null +++ b/packages/admin-client/src/features/experience/infrastructure/experience.api.ts @@ -0,0 +1,58 @@ +import api from '../../../shared/utils/api'; + +export interface Experience { + id: string; + experienceType: string; + content: string; + scenario: string; + confidence: number; + relatedCategory: string; + sourceConversationIds: string[]; + verificationStatus: string; + usageCount: number; + positiveCount: number; + negativeCount: number; + isActive: boolean; + createdAt: string; +} + +export interface ExperienceListResponse { + items: Experience[]; + total: number; +} + +export interface ExperienceStatistics { + total: number; + byStatus: Record; + byType: Record; +} + +export const experienceApi = { + getPendingExperiences: async (type?: string): Promise => { + const params = new URLSearchParams(); + if (type) params.append('type', type); + const response = await api.get(`/memory/experience/pending?${params}`); + return response.data.data; + }, + + getStatistics: async (): Promise => { + const response = await api.get('/memory/experience/statistics'); + return response.data.data; + }, + + approveExperience: async (id: string, adminId: string): Promise => { + await api.post(`/memory/experience/${id}/approve`, { adminId }); + }, + + rejectExperience: async (id: string, adminId: string): Promise => { + await api.post(`/memory/experience/${id}/reject`, { adminId }); + }, + + runEvolution: async (hoursBack: number = 24, limit: number = 50): Promise<{ + conversationsAnalyzed: number; + experiencesExtracted: number; + }> => { + const response = await api.post('/evolution/run', { hoursBack, limit }); + return response.data.data; + }, +}; diff --git a/packages/admin-client/src/features/experience/infrastructure/index.ts b/packages/admin-client/src/features/experience/infrastructure/index.ts new file mode 100644 index 0000000..0423522 --- /dev/null +++ b/packages/admin-client/src/features/experience/infrastructure/index.ts @@ -0,0 +1,2 @@ +export { experienceApi } from './experience.api'; +export type { Experience, ExperienceListResponse, ExperienceStatistics } from './experience.api'; diff --git a/packages/admin-client/src/features/experience/presentation/pages/ExperiencePage.tsx b/packages/admin-client/src/features/experience/presentation/pages/ExperiencePage.tsx index 9ec84fc..0d43e48 100644 --- a/packages/admin-client/src/features/experience/presentation/pages/ExperiencePage.tsx +++ b/packages/admin-client/src/features/experience/presentation/pages/ExperiencePage.tsx @@ -1,5 +1,4 @@ import { useState } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Card, Table, @@ -8,7 +7,6 @@ import { Tag, Space, Modal, - message, Tabs, Typography, Statistic, @@ -21,8 +19,15 @@ import { EyeOutlined, PlayCircleOutlined, } from '@ant-design/icons'; -import api from '../../../../shared/utils/api'; -import { useAuth } from '../../../../shared/hooks/useAuth'; +import { useAuthStore } from '../../../auth/application'; +import { + usePendingExperiences, + useExperienceStatistics, + useApproveExperience, + useRejectExperience, + useRunEvolution, +} from '../../application'; +import type { Experience } from '../../infrastructure'; const { Title, Text, Paragraph } = Typography; @@ -37,86 +42,39 @@ const EXPERIENCE_TYPES = [ { value: 'OBJECTION_HANDLING', label: '异议处理' }, ]; -interface Experience { - id: string; - experienceType: string; - content: string; - scenario: string; - confidence: number; - relatedCategory: string; - sourceConversationIds: string[]; - verificationStatus: string; - usageCount: number; - positiveCount: number; - negativeCount: number; - isActive: boolean; - createdAt: string; -} - export function ExperiencePage() { const [activeTab, setActiveTab] = useState('pending'); const [typeFilter, setTypeFilter] = useState(); const [selectedExperience, setSelectedExperience] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); - const queryClient = useQueryClient(); - const admin = useAuth((state) => state.admin); + const admin = useAuthStore((state) => state.admin); - const { data: pendingData, isLoading: pendingLoading } = useQuery({ - queryKey: ['pending-experiences', typeFilter], - queryFn: async () => { - const params = new URLSearchParams(); - if (typeFilter) params.append('type', typeFilter); - const response = await api.get(`/memory/experience/pending?${params}`); - return response.data.data; - }, - enabled: activeTab === 'pending', - }); - - const { data: stats } = useQuery({ - queryKey: ['experience-stats'], - queryFn: async () => { - const response = await api.get('/memory/experience/statistics'); - return response.data.data; - }, - }); - - const approveMutation = useMutation({ - mutationFn: (id: string) => - api.post(`/memory/experience/${id}/approve`, { adminId: admin?.id }), - onSuccess: () => { - message.success('经验已批准'); - queryClient.invalidateQueries({ queryKey: ['pending-experiences'] }); - queryClient.invalidateQueries({ queryKey: ['experience-stats'] }); - }, - }); - - const rejectMutation = useMutation({ - mutationFn: (id: string) => - api.post(`/memory/experience/${id}/reject`, { adminId: admin?.id }), - onSuccess: () => { - message.success('经验已拒绝'); - queryClient.invalidateQueries({ queryKey: ['pending-experiences'] }); - queryClient.invalidateQueries({ queryKey: ['experience-stats'] }); - }, - }); - - const runEvolutionMutation = useMutation({ - mutationFn: () => api.post('/evolution/run', { hoursBack: 24, limit: 50 }), - onSuccess: (response) => { - const result = response.data.data; - message.success( - `进化任务完成:分析了${result.conversationsAnalyzed}个对话,提取了${result.experiencesExtracted}条经验` - ); - queryClient.invalidateQueries({ queryKey: ['pending-experiences'] }); - queryClient.invalidateQueries({ queryKey: ['experience-stats'] }); - }, - }); + const { data: pendingData, isLoading: pendingLoading } = usePendingExperiences( + typeFilter, + activeTab === 'pending' + ); + const { data: stats } = useExperienceStatistics(); + const approveMutation = useApproveExperience(); + const rejectMutation = useRejectExperience(); + const runEvolutionMutation = useRunEvolution(); const handleView = (exp: Experience) => { setSelectedExperience(exp); setIsModalOpen(true); }; + const handleApprove = (id: string) => { + if (admin?.id) { + approveMutation.mutate({ id, adminId: admin.id }); + } + }; + + const handleReject = (id: string) => { + if (admin?.id) { + rejectMutation.mutate({ id, adminId: admin.id }); + } + }; + const getTypeLabel = (type: string) => { return EXPERIENCE_TYPES.find((t) => t.value === type)?.label || type; }; @@ -202,14 +160,14 @@ export function ExperiencePage() { type="text" icon={} className="text-green-600" - onClick={() => approveMutation.mutate(record.id)} + onClick={() => handleApprove(record.id)} loading={approveMutation.isPending} />