refactor(admin-client): implement 3-layer Clean Architecture for frontend
Refactored admin-client from 1.5-layer to 3-layer architecture using
Feature-Sliced Design pattern with Zustand + TanStack Query.
## Architecture Pattern
Each feature now follows 3-layer structure:
```
features/{feature}/
├── presentation/ # React UI components, pages
├── application/ # Zustand stores, TanStack Query hooks
└── infrastructure/ # API clients (axios calls)
```
## Changes by Feature
### Auth Feature
- infrastructure/auth.api.ts: Login, verify API calls
- application/useAuthStore.ts: Zustand store for auth state
- Updated LoginPage.tsx to use useAuthStore
- shared/hooks/useAuth.ts: Re-exports for backward compatibility
### Knowledge Feature
- infrastructure/knowledge.api.ts: Article CRUD APIs
- application/useKnowledge.ts: TanStack Query hooks
- useKnowledgeArticles, useCreateArticle, useUpdateArticle
- useDeleteArticle, usePublishArticle, useUnpublishArticle
- Updated KnowledgePage.tsx to use application hooks
### Experience Feature
- infrastructure/experience.api.ts: Experience management APIs
- application/useExperience.ts: TanStack Query hooks
- usePendingExperiences, useExperienceStatistics
- useApproveExperience, useRejectExperience, useRunEvolution
- Updated ExperiencePage.tsx to use application hooks
### Dashboard Feature
- infrastructure/dashboard.api.ts: Statistics APIs
- application/useDashboard.ts: TanStack Query hooks
- useEvolutionStatistics, useSystemHealth
- Updated DashboardPage.tsx to use application hooks
## Benefits
- Clear separation of concerns (UI / business logic / data access)
- Better testability (each layer can be tested independently)
- Reusable hooks across components
- Type-safe API interfaces
- Centralized API error handling
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
02954f56db
commit
9e1dca25f2
|
|
@ -0,0 +1 @@
|
|||
export { useAuthStore } from './useAuthStore';
|
||||
|
|
@ -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<void>;
|
||||
logout: () => void;
|
||||
checkAuth: () => Promise<boolean>;
|
||||
hasPermission: (permission: string) => boolean;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
@ -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<LoginResponse> => {
|
||||
const response = await api.post('/admin/login', { username, password });
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
verify: async (): Promise<AdminInfo> => {
|
||||
const response = await api.get('/admin/verify');
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { authApi } from './auth.api';
|
||||
export type { AdminInfo, LoginResponse } from './auth.api';
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
export {
|
||||
useEvolutionStatistics,
|
||||
useSystemHealth,
|
||||
EVOLUTION_STATS_KEY,
|
||||
SYSTEM_HEALTH_KEY,
|
||||
} from './useDashboard';
|
||||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
|
|
@ -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<EvolutionStatistics> => {
|
||||
const response = await api.get('/evolution/statistics');
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getSystemHealth: async (): Promise<HealthReport> => {
|
||||
const response = await api.get('/evolution/health');
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { dashboardApi } from './dashboard.api';
|
||||
export type { EvolutionStatistics, HealthMetric, HealthReport } from './dashboard.api';
|
||||
|
|
@ -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() {
|
|||
>
|
||||
<List
|
||||
dataSource={healthReport?.metrics || []}
|
||||
renderItem={(item: { name: string; value: number; threshold: number; status: string }) => (
|
||||
renderItem={(item: HealthMetric) => (
|
||||
<List.Item>
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between mb-1">
|
||||
|
|
@ -232,11 +219,11 @@ export function DashboardPage() {
|
|||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
{healthReport?.recommendations?.length > 0 && (
|
||||
{(healthReport?.recommendations?.length ?? 0) > 0 && (
|
||||
<div className="mt-4 p-3 bg-yellow-50 rounded">
|
||||
<Text type="warning">建议:</Text>
|
||||
<ul className="mt-2 ml-4 text-sm text-gray-600">
|
||||
{healthReport.recommendations.map((rec: string, i: number) => (
|
||||
{healthReport?.recommendations?.map((rec: string, i: number) => (
|
||||
<li key={i}>{rec}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
export {
|
||||
usePendingExperiences,
|
||||
useExperienceStatistics,
|
||||
useApproveExperience,
|
||||
useRejectExperience,
|
||||
useRunEvolution,
|
||||
EXPERIENCE_QUERY_KEY,
|
||||
EXPERIENCE_STATS_KEY,
|
||||
} from './useExperience';
|
||||
|
|
@ -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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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<string, number>;
|
||||
byType: Record<string, number>;
|
||||
}
|
||||
|
||||
export const experienceApi = {
|
||||
getPendingExperiences: async (type?: string): Promise<ExperienceListResponse> => {
|
||||
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<ExperienceStatistics> => {
|
||||
const response = await api.get('/memory/experience/statistics');
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
approveExperience: async (id: string, adminId: string): Promise<void> => {
|
||||
await api.post(`/memory/experience/${id}/approve`, { adminId });
|
||||
},
|
||||
|
||||
rejectExperience: async (id: string, adminId: string): Promise<void> => {
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { experienceApi } from './experience.api';
|
||||
export type { Experience, ExperienceListResponse, ExperienceStatistics } from './experience.api';
|
||||
|
|
@ -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<string>();
|
||||
const [selectedExperience, setSelectedExperience] = useState<Experience | null>(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={<CheckOutlined />}
|
||||
className="text-green-600"
|
||||
onClick={() => approveMutation.mutate(record.id)}
|
||||
onClick={() => handleApprove(record.id)}
|
||||
loading={approveMutation.isPending}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
danger
|
||||
onClick={() => rejectMutation.mutate(record.id)}
|
||||
onClick={() => handleReject(record.id)}
|
||||
loading={rejectMutation.isPending}
|
||||
/>
|
||||
</>
|
||||
|
|
@ -226,7 +184,7 @@ export function ExperiencePage() {
|
|||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => runEvolutionMutation.mutate()}
|
||||
onClick={() => runEvolutionMutation.mutate({})}
|
||||
loading={runEvolutionMutation.isPending}
|
||||
>
|
||||
运行进化任务
|
||||
|
|
@ -318,7 +276,7 @@ export function ExperiencePage() {
|
|||
key="reject"
|
||||
danger
|
||||
onClick={() => {
|
||||
rejectMutation.mutate(selectedExperience.id);
|
||||
handleReject(selectedExperience.id);
|
||||
setIsModalOpen(false);
|
||||
}}
|
||||
>
|
||||
|
|
@ -328,7 +286,7 @@ export function ExperiencePage() {
|
|||
key="approve"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
approveMutation.mutate(selectedExperience.id);
|
||||
handleApprove(selectedExperience.id);
|
||||
setIsModalOpen(false);
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
export {
|
||||
useKnowledgeArticles,
|
||||
useCreateArticle,
|
||||
useUpdateArticle,
|
||||
useDeleteArticle,
|
||||
usePublishArticle,
|
||||
useUnpublishArticle,
|
||||
KNOWLEDGE_QUERY_KEY,
|
||||
} from './useKnowledge';
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { message } from 'antd';
|
||||
import { knowledgeApi, CreateArticleParams, UpdateArticleParams } from '../infrastructure/knowledge.api';
|
||||
|
||||
export const KNOWLEDGE_QUERY_KEY = 'knowledge-articles';
|
||||
|
||||
export function useKnowledgeArticles(category?: string) {
|
||||
return useQuery({
|
||||
queryKey: [KNOWLEDGE_QUERY_KEY, category],
|
||||
queryFn: () => knowledgeApi.getArticles(category),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateArticle() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: CreateArticleParams) => knowledgeApi.createArticle(params),
|
||||
onSuccess: () => {
|
||||
message.success('文章创建成功');
|
||||
queryClient.invalidateQueries({ queryKey: [KNOWLEDGE_QUERY_KEY] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateArticle() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: UpdateArticleParams) => knowledgeApi.updateArticle(params),
|
||||
onSuccess: () => {
|
||||
message.success('文章更新成功');
|
||||
queryClient.invalidateQueries({ queryKey: [KNOWLEDGE_QUERY_KEY] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteArticle() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => knowledgeApi.deleteArticle(id),
|
||||
onSuccess: () => {
|
||||
message.success('文章已删除');
|
||||
queryClient.invalidateQueries({ queryKey: [KNOWLEDGE_QUERY_KEY] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePublishArticle() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => knowledgeApi.publishArticle(id),
|
||||
onSuccess: () => {
|
||||
message.success('文章已发布');
|
||||
queryClient.invalidateQueries({ queryKey: [KNOWLEDGE_QUERY_KEY] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnpublishArticle() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => knowledgeApi.unpublishArticle(id),
|
||||
onSuccess: () => {
|
||||
message.success('文章已取消发布');
|
||||
queryClient.invalidateQueries({ queryKey: [KNOWLEDGE_QUERY_KEY] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { knowledgeApi } from './knowledge.api';
|
||||
export type { Article, ArticleListResponse, CreateArticleParams, UpdateArticleParams } from './knowledge.api';
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import api from '../../../shared/utils/api';
|
||||
|
||||
export interface Article {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
summary: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
source: string;
|
||||
isPublished: boolean;
|
||||
citationCount: number;
|
||||
qualityScore: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ArticleListResponse {
|
||||
items: Article[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CreateArticleParams {
|
||||
title: string;
|
||||
content: string;
|
||||
category: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateArticleParams extends Partial<CreateArticleParams> {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const knowledgeApi = {
|
||||
getArticles: async (category?: string): Promise<ArticleListResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (category) params.append('category', category);
|
||||
const response = await api.get(`/knowledge/articles?${params}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
createArticle: async (params: CreateArticleParams): Promise<Article> => {
|
||||
const response = await api.post('/knowledge/articles', params);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
updateArticle: async ({ id, ...params }: UpdateArticleParams): Promise<Article> => {
|
||||
const response = await api.put(`/knowledge/articles/${id}`, params);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
deleteArticle: async (id: string): Promise<void> => {
|
||||
await api.delete(`/knowledge/articles/${id}`);
|
||||
},
|
||||
|
||||
publishArticle: async (id: string): Promise<void> => {
|
||||
await api.post(`/knowledge/articles/${id}/publish`);
|
||||
},
|
||||
|
||||
unpublishArticle: async (id: string): Promise<void> => {
|
||||
await api.post(`/knowledge/articles/${id}/unpublish`);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
|
|
@ -10,7 +9,6 @@ import {
|
|||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
message,
|
||||
Popconfirm,
|
||||
Typography,
|
||||
Drawer,
|
||||
|
|
@ -24,7 +22,15 @@ import {
|
|||
CheckOutlined,
|
||||
StopOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import api from '../../../../shared/utils/api';
|
||||
import {
|
||||
useKnowledgeArticles,
|
||||
useCreateArticle,
|
||||
useUpdateArticle,
|
||||
useDeleteArticle,
|
||||
usePublishArticle,
|
||||
useUnpublishArticle,
|
||||
} from '../../application';
|
||||
import type { Article, CreateArticleParams } from '../../infrastructure';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
|
@ -39,20 +45,6 @@ const CATEGORIES = [
|
|||
{ value: 'GENERAL', label: '通用知识' },
|
||||
];
|
||||
|
||||
interface Article {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
summary: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
source: string;
|
||||
isPublished: boolean;
|
||||
citationCount: number;
|
||||
qualityScore: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function KnowledgePage() {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>();
|
||||
|
|
@ -60,64 +52,13 @@ export function KnowledgePage() {
|
|||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [selectedArticle, setSelectedArticle] = useState<Article | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['knowledge-articles', categoryFilter],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (categoryFilter) params.append('category', categoryFilter);
|
||||
const response = await api.get(`/knowledge/articles?${params}`);
|
||||
return response.data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (values: Partial<Article>) =>
|
||||
api.post('/knowledge/articles', values),
|
||||
onSuccess: () => {
|
||||
message.success('文章创建成功');
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge-articles'] });
|
||||
setIsModalOpen(false);
|
||||
form.resetFields();
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, ...values }: { id: string } & Partial<Article>) =>
|
||||
api.put(`/knowledge/articles/${id}`, values),
|
||||
onSuccess: () => {
|
||||
message.success('文章更新成功');
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge-articles'] });
|
||||
setIsModalOpen(false);
|
||||
form.resetFields();
|
||||
setSelectedArticle(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/knowledge/articles/${id}`),
|
||||
onSuccess: () => {
|
||||
message.success('文章已删除');
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge-articles'] });
|
||||
},
|
||||
});
|
||||
|
||||
const publishMutation = useMutation({
|
||||
mutationFn: (id: string) => api.post(`/knowledge/articles/${id}/publish`),
|
||||
onSuccess: () => {
|
||||
message.success('文章已发布');
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge-articles'] });
|
||||
},
|
||||
});
|
||||
|
||||
const unpublishMutation = useMutation({
|
||||
mutationFn: (id: string) => api.post(`/knowledge/articles/${id}/unpublish`),
|
||||
onSuccess: () => {
|
||||
message.success('文章已取消发布');
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge-articles'] });
|
||||
},
|
||||
});
|
||||
const { data, isLoading } = useKnowledgeArticles(categoryFilter);
|
||||
const createMutation = useCreateArticle();
|
||||
const updateMutation = useUpdateArticle();
|
||||
const deleteMutation = useDeleteArticle();
|
||||
const publishMutation = usePublishArticle();
|
||||
const unpublishMutation = useUnpublishArticle();
|
||||
|
||||
const handleEdit = (article: Article) => {
|
||||
setSelectedArticle(article);
|
||||
|
|
@ -135,11 +76,25 @@ export function KnowledgePage() {
|
|||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = (values: Partial<Article>) => {
|
||||
const handleSubmit = (values: CreateArticleParams) => {
|
||||
if (selectedArticle) {
|
||||
updateMutation.mutate({ id: selectedArticle.id, ...values });
|
||||
updateMutation.mutate(
|
||||
{ id: selectedArticle.id, ...values },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsModalOpen(false);
|
||||
form.resetFields();
|
||||
setSelectedArticle(null);
|
||||
},
|
||||
}
|
||||
);
|
||||
} else {
|
||||
createMutation.mutate(values);
|
||||
createMutation.mutate(values, {
|
||||
onSuccess: () => {
|
||||
setIsModalOpen(false);
|
||||
form.resetFields();
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,105 +1,3 @@
|
|||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import api from '../utils/api';
|
||||
|
||||
interface AdminInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
admin: AdminInfo | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
checkAuth: () => Promise<boolean>;
|
||||
hasPermission: (permission: string) => boolean;
|
||||
}
|
||||
|
||||
export const useAuth = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
admin: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
login: async (username: string, password: string) => {
|
||||
const response = await api.post('/admin/login', { username, password });
|
||||
const { data } = response.data;
|
||||
|
||||
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 response = await api.get('/admin/verify');
|
||||
if (response.data.success) {
|
||||
set({
|
||||
admin: response.data.data,
|
||||
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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
// Re-export from auth feature for backward compatibility
|
||||
export { useAuthStore as useAuth } from '../../features/auth/application';
|
||||
export type { AdminInfo } from '../../features/auth/infrastructure';
|
||||
|
|
|
|||
Loading…
Reference in New Issue