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:
hailin 2026-01-24 22:17:48 -08:00
parent 02954f56db
commit 9e1dca25f2
21 changed files with 536 additions and 281 deletions

View File

@ -0,0 +1 @@
export { useAuthStore } from './useAuthStore';

View File

@ -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,
}),
}
)
);

View File

@ -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;
},
};

View File

@ -0,0 +1,2 @@
export { authApi } from './auth.api';
export type { AdminInfo, LoginResponse } from './auth.api';

View File

@ -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);

View File

@ -0,0 +1,6 @@
export {
useEvolutionStatistics,
useSystemHealth,
EVOLUTION_STATS_KEY,
SYSTEM_HEALTH_KEY,
} from './useDashboard';

View File

@ -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(),
});
}

View File

@ -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;
},
};

View File

@ -0,0 +1,2 @@
export { dashboardApi } from './dashboard.api';
export type { EvolutionStatistics, HealthMetric, HealthReport } from './dashboard.api';

View File

@ -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>

View File

@ -0,0 +1,9 @@
export {
usePendingExperiences,
useExperienceStatistics,
useApproveExperience,
useRejectExperience,
useRunEvolution,
EXPERIENCE_QUERY_KEY,
EXPERIENCE_STATS_KEY,
} from './useExperience';

View File

@ -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] });
},
});
}

View File

@ -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;
},
};

View File

@ -0,0 +1,2 @@
export { experienceApi } from './experience.api';
export type { Experience, ExperienceListResponse, ExperienceStatistics } from './experience.api';

View File

@ -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);
}}
>

View File

@ -0,0 +1,9 @@
export {
useKnowledgeArticles,
useCreateArticle,
useUpdateArticle,
useDeleteArticle,
usePublishArticle,
useUnpublishArticle,
KNOWLEDGE_QUERY_KEY,
} from './useKnowledge';

View File

@ -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] });
},
});
}

View File

@ -0,0 +1,2 @@
export { knowledgeApi } from './knowledge.api';
export type { Article, ArticleListResponse, CreateArticleParams, UpdateArticleParams } from './knowledge.api';

View File

@ -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`);
},
};

View File

@ -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();
},
});
}
};

View File

@ -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';