feat(admin-client): add manual experience creation in ExperiencePage

管理员现在可以在"系统经验管理"页面手动创建经验,而不仅限于审核系统自动生成的经验。

实现细节:
- experience.api.ts: 新增 CreateExperienceDto 类型和 createExperience() API 方法
  - 调用 POST /memory/experience 创建经验
  - 创建后自动调用 POST /memory/experience/:id/approve 激活
  - 管理员手动创建的经验无需额外审核流程
  - sourceConversationId 标记为 'admin-manual' 以区分来源
  - 默认置信度 80%(高于系统自动生成的 45%)
- useExperience.ts: 新增 useCreateExperience mutation hook
  - 创建成功后自动刷新经验列表和统计数据
- ExperiencePage.tsx: 新增"新建经验"按钮和创建表单弹窗
  - 表单字段:经验类型、适用场景、经验内容、相关移民类别(可选)、置信度
  - 移民类别下拉:QMAS/GEP/IANG/TTPS/CIES/TechTAS
  - 表单验证:类型、场景、内容为必填

这与方案A(评估门控失败自动沉淀经验)互补:
- 自动路径:Gate failure → PENDING experience → 管理员审核 → 激活
- 手动路径:管理员直接创建 → 自动激活(无需审核)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-06 22:09:47 -08:00
parent 04dbc61131
commit cc4b7d50e3
5 changed files with 155 additions and 10 deletions

View File

@ -3,6 +3,7 @@ export {
useExperienceStatistics,
useApproveExperience,
useRejectExperience,
useCreateExperience,
useRunEvolution,
EXPERIENCE_QUERY_KEY,
EXPERIENCE_STATS_KEY,

View File

@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { message } from 'antd';
import { experienceApi } from '../infrastructure/experience.api';
import { experienceApi, type CreateExperienceDto } from '../infrastructure/experience.api';
export const EXPERIENCE_QUERY_KEY = 'pending-experiences';
export const EXPERIENCE_STATS_KEY = 'experience-stats';
@ -48,6 +48,23 @@ export function useRejectExperience() {
});
}
export function useCreateExperience() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: CreateExperienceDto) =>
experienceApi.createExperience(dto),
onSuccess: () => {
message.success('经验已创建并自动激活');
queryClient.invalidateQueries({ queryKey: [EXPERIENCE_QUERY_KEY] });
queryClient.invalidateQueries({ queryKey: [EXPERIENCE_STATS_KEY] });
},
onError: () => {
message.error('创建经验失败');
},
});
}
export function useRunEvolution() {
const queryClient = useQueryClient();

View File

@ -27,6 +27,14 @@ export interface ExperienceStatistics {
byType: Record<string, number>;
}
export interface CreateExperienceDto {
experienceType: string;
content: string;
scenario: string;
relatedCategory?: string;
confidence?: number;
}
export const experienceApi = {
getPendingExperiences: async (type?: string): Promise<ExperienceListResponse> => {
const params = new URLSearchParams();
@ -48,6 +56,23 @@ export const experienceApi = {
await api.post(`/memory/experience/${id}/reject`, { adminId });
},
createExperience: async (dto: CreateExperienceDto): Promise<Experience> => {
// 管理员手动创建先创建PENDING再自动批准APPROVED + isActive
const createResponse = await api.post('/memory/experience', {
...dto,
sourceConversationId: 'admin-manual',
confidence: dto.confidence ?? 80,
});
const created = createResponse.data.data as Experience;
// 自动批准 — 管理员手动创建的经验无需再审核
await api.post(`/memory/experience/${created.id}/approve`, {
adminId: 'admin-manual',
});
return created;
},
runEvolution: async (hoursBack: number = 24, limit: number = 50): Promise<{
conversationsAnalyzed: number;
experiencesExtracted: number;

View File

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

View File

@ -12,12 +12,16 @@ import {
Statistic,
Row,
Col,
Form,
Input,
InputNumber,
} from 'antd';
import {
CheckOutlined,
CloseOutlined,
EyeOutlined,
PlayCircleOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { useAuthStore } from '../../../auth/application';
import {
@ -25,11 +29,13 @@ import {
useExperienceStatistics,
useApproveExperience,
useRejectExperience,
useCreateExperience,
useRunEvolution,
} from '../../application';
import type { Experience } from '../../infrastructure';
const { Title, Text, Paragraph } = Typography;
const { TextArea } = Input;
const EXPERIENCE_TYPES = [
{ value: 'COMMON_QUESTION', label: '常见问题' },
@ -47,6 +53,8 @@ export function ExperiencePage() {
const [typeFilter, setTypeFilter] = useState<string>();
const [selectedExperience, setSelectedExperience] = useState<Experience | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [createForm] = Form.useForm();
const admin = useAuthStore((state) => state.admin);
const { data: pendingData, isLoading: pendingLoading } = usePendingExperiences(
@ -56,6 +64,7 @@ export function ExperiencePage() {
const { data: stats } = useExperienceStatistics();
const approveMutation = useApproveExperience();
const rejectMutation = useRejectExperience();
const createMutation = useCreateExperience();
const runEvolutionMutation = useRunEvolution();
const handleView = (exp: Experience) => {
@ -75,6 +84,20 @@ export function ExperiencePage() {
}
};
const handleCreate = async () => {
try {
const values = await createForm.validateFields();
createMutation.mutate(values, {
onSuccess: () => {
setIsCreateModalOpen(false);
createForm.resetFields();
},
});
} catch {
// validation error — form will show inline errors
}
};
const getTypeLabel = (type: string) => {
return EXPERIENCE_TYPES.find((t) => t.value === type)?.label || type;
};
@ -181,14 +204,22 @@ export function ExperiencePage() {
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<Title level={4} className="mb-0"></Title>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => runEvolutionMutation.mutate({})}
loading={runEvolutionMutation.isPending}
>
</Button>
<Space>
<Button
icon={<PlusOutlined />}
onClick={() => setIsCreateModalOpen(true)}
>
</Button>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => runEvolutionMutation.mutate({})}
loading={runEvolutionMutation.isPending}
>
</Button>
</Space>
</div>
{/* 统计卡片 */}
@ -346,6 +377,77 @@ export function ExperiencePage() {
</div>
)}
</Modal>
{/* 新建经验弹窗 */}
<Modal
title="新建经验"
open={isCreateModalOpen}
onCancel={() => {
setIsCreateModalOpen(false);
createForm.resetFields();
}}
onOk={handleCreate}
okText="创建"
cancelText="取消"
confirmLoading={createMutation.isPending}
width={600}
>
<Form form={createForm} layout="vertical">
<Form.Item
name="experienceType"
label="经验类型"
rules={[{ required: true, message: '请选择经验类型' }]}
>
<Select placeholder="选择类型" options={EXPERIENCE_TYPES} />
</Form.Item>
<Form.Item
name="scenario"
label="适用场景"
rules={[{ required: true, message: '请描述适用场景' }]}
>
<Input placeholder="例如用户咨询QMAS签证费用时" />
</Form.Item>
<Form.Item
name="content"
label="经验内容"
rules={[{ required: true, message: '请填写经验内容' }]}
>
<TextArea
rows={4}
placeholder="例如:回答费用问题时应引用官方数据并标注更新日期,避免给出过期信息"
/>
</Form.Item>
<Form.Item name="relatedCategory" label="相关移民类别">
<Select
placeholder="可选"
allowClear
options={[
{ value: 'QMAS', label: '优才计划 (QMAS)' },
{ value: 'GEP', label: '一般就业政策 (GEP)' },
{ value: 'IANG', label: '非本地毕业生 (IANG)' },
{ value: 'TTPS', label: '科技人才 (TTPS)' },
{ value: 'CIES', label: '资本投资者 (CIES)' },
{ value: 'TECHTAS', label: '人才清单 (TechTAS)' },
]}
/>
</Form.Item>
<Form.Item
name="confidence"
label="置信度"
initialValue={80}
>
<InputNumber min={0} max={100} addonAfter="%" style={{ width: '100%' }} />
</Form.Item>
<Text type="secondary">
</Text>
</Form>
</Modal>
</div>
);
}