iconsulting/packages/admin-client/src/features/knowledge/presentation/pages/KnowledgePage.tsx

423 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react';
import {
Card,
Table,
Button,
Input,
Select,
Tag,
Space,
Modal,
Form,
Popconfirm,
Typography,
Drawer,
Upload,
Segmented,
message,
} from 'antd';
import {
PlusOutlined,
SearchOutlined,
EditOutlined,
DeleteOutlined,
EyeOutlined,
CheckOutlined,
StopOutlined,
InboxOutlined,
UploadOutlined,
} from '@ant-design/icons';
import {
useKnowledgeArticles,
useCreateArticle,
useUpdateArticle,
useDeleteArticle,
usePublishArticle,
useUnpublishArticle,
useUploadKnowledgeFile,
} from '../../application';
import type { Article, CreateArticleParams } from '../../infrastructure';
const { Title, Paragraph } = Typography;
const { TextArea } = Input;
const CATEGORIES = [
{ value: 'QMAS', label: '优秀人才入境计划' },
{ value: 'GEP', label: '一般就业政策' },
{ value: 'IANG', label: '非本地毕业生留港/回港就业安排' },
{ value: 'TTPS', label: '科技人才入境计划' },
{ value: 'CIES', label: '资本投资者入境计划' },
{ value: 'TechTAS', label: '顶尖人才通行证计划' },
{ value: 'GENERAL', label: '通用知识' },
];
export function KnowledgePage() {
const [searchText, setSearchText] = useState('');
const [categoryFilter, setCategoryFilter] = useState<string>();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [selectedArticle, setSelectedArticle] = useState<Article | null>(null);
const [inputMode, setInputMode] = useState<'manual' | 'upload'>('manual');
const [isExtracting, setIsExtracting] = useState(false);
const [form] = Form.useForm();
const { data, isLoading } = useKnowledgeArticles(categoryFilter);
const createMutation = useCreateArticle();
const updateMutation = useUpdateArticle();
const deleteMutation = useDeleteArticle();
const publishMutation = usePublishArticle();
const unpublishMutation = useUnpublishArticle();
const uploadMutation = useUploadKnowledgeFile();
const handleEdit = (article: Article) => {
setSelectedArticle(article);
form.setFieldsValue({
title: article.title,
content: article.content,
category: article.category,
tags: article.tags,
});
setIsModalOpen(true);
};
const handleView = (article: Article) => {
setSelectedArticle(article);
setIsDrawerOpen(true);
};
const handleSubmit = (values: CreateArticleParams) => {
if (selectedArticle) {
updateMutation.mutate(
{ id: selectedArticle.id, ...values },
{
onSuccess: () => {
setIsModalOpen(false);
form.resetFields();
setSelectedArticle(null);
},
}
);
} else {
createMutation.mutate(values, {
onSuccess: () => {
setIsModalOpen(false);
form.resetFields();
},
});
}
};
const handleFileUpload = (file: File) => {
setIsExtracting(true);
uploadMutation.mutate(file, {
onSuccess: (result) => {
form.setFieldsValue({
title: result.suggestedTitle,
content: result.extractedText,
});
const info = result.pageCount
? `已提取 ${result.wordCount} 字(${result.pageCount} 页)`
: `已提取 ${result.wordCount}`;
message.success(info);
setInputMode('manual');
},
onSettled: () => setIsExtracting(false),
});
return false; // prevent default upload
};
const columns = [
{
title: '标题',
dataIndex: 'title',
key: 'title',
render: (text: string, record: Article) => (
<a onClick={() => handleView(record)}>{text}</a>
),
},
{
title: '类别',
dataIndex: 'category',
key: 'category',
render: (category: string) => {
const cat = CATEGORIES.find((c) => c.value === category);
return <Tag color="blue">{cat?.label || category}</Tag>;
},
},
{
title: '来源',
dataIndex: 'source',
key: 'source',
render: (source: string) => {
const sourceMap: Record<string, { color: string; label: string }> = {
MANUAL: { color: 'green', label: '手动' },
CRAWL: { color: 'orange', label: '爬取' },
EXTRACT: { color: 'purple', label: '提取' },
IMPORT: { color: 'cyan', label: '导入' },
};
const s = sourceMap[source] || { color: 'default', label: source };
return <Tag color={s.color}>{s.label}</Tag>;
},
},
{
title: '状态',
dataIndex: 'isPublished',
key: 'isPublished',
render: (isPublished: boolean) =>
isPublished ? (
<Tag color="success"></Tag>
) : (
<Tag color="default">稿</Tag>
),
},
{
title: '质量分',
dataIndex: 'qualityScore',
key: 'qualityScore',
render: (score: number) => (
<span
className={
score >= 70
? 'text-green-600'
: score >= 40
? 'text-yellow-600'
: 'text-red-600'
}
>
{score}
</span>
),
},
{
title: '引用次数',
dataIndex: 'citationCount',
key: 'citationCount',
},
{
title: '操作',
key: 'action',
render: (_: unknown, record: Article) => (
<Space size="small">
<Button
type="text"
icon={<EyeOutlined />}
onClick={() => handleView(record)}
/>
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
/>
{record.isPublished ? (
<Popconfirm
title="确定取消发布?"
onConfirm={() => unpublishMutation.mutate(record.id)}
>
<Button type="text" icon={<StopOutlined />} />
</Popconfirm>
) : (
<Button
type="text"
icon={<CheckOutlined />}
onClick={() => publishMutation.mutate(record.id)}
/>
)}
<Popconfirm
title="确定删除此文章?"
onConfirm={() => deleteMutation.mutate(record.id)}
>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<div className="p-6">
<Title level={4} className="mb-6"></Title>
<Card>
<div className="flex justify-between mb-4">
<Space>
<Input
placeholder="搜索文章"
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 200 }}
/>
<Select
placeholder="选择类别"
allowClear
style={{ width: 180 }}
value={categoryFilter}
onChange={setCategoryFilter}
options={CATEGORIES}
/>
</Space>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setSelectedArticle(null);
form.resetFields();
setInputMode('manual');
setIsModalOpen(true);
}}
>
</Button>
</div>
<Table
columns={columns}
dataSource={data?.items || []}
rowKey="id"
loading={isLoading}
pagination={{
total: data?.total || 0,
pageSize: 20,
showSizeChanger: false,
}}
/>
</Card>
{/* 编辑/新建弹窗 */}
<Modal
title={selectedArticle ? '编辑文章' : '新建文章'}
open={isModalOpen}
onCancel={() => {
setIsModalOpen(false);
setSelectedArticle(null);
form.resetFields();
}}
footer={null}
width={800}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item
name="title"
label="标题"
rules={[{ required: true, message: '请输入标题' }]}
>
<Input placeholder="文章标题" />
</Form.Item>
<Form.Item
name="category"
label="类别"
rules={[{ required: true, message: '请选择类别' }]}
>
<Select options={CATEGORIES} placeholder="选择移民类别" />
</Form.Item>
<Form.Item name="tags" label="标签">
<Select mode="tags" placeholder="输入标签后回车" />
</Form.Item>
{!selectedArticle && (
<Form.Item label="内容来源">
<Segmented
value={inputMode}
onChange={(val) => setInputMode(val as 'manual' | 'upload')}
options={[
{ label: '手动输入', value: 'manual', icon: <EditOutlined /> },
{ label: '文件上传', value: 'upload', icon: <UploadOutlined /> },
]}
/>
</Form.Item>
)}
{inputMode === 'upload' && !selectedArticle ? (
<Form.Item label="上传文件">
<Upload.Dragger
accept=".pdf,.docx,.txt,.md"
showUploadList={false}
beforeUpload={handleFileUpload}
disabled={isExtracting}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">
{isExtracting ? '正在提取文本...' : '点击或拖拽文件到此区域'}
</p>
<p className="ant-upload-hint">
PDFWord(.docx)TXTMarkdown 200MB
</p>
</Upload.Dragger>
</Form.Item>
) : (
<Form.Item
name="content"
label="内容"
rules={[{ required: true, message: '请输入内容' }]}
>
<TextArea rows={12} placeholder="支持Markdown格式" />
</Form.Item>
)}
<Form.Item className="mb-0 text-right">
<Space>
<Button onClick={() => setIsModalOpen(false)}></Button>
<Button
type="primary"
htmlType="submit"
loading={createMutation.isPending || updateMutation.isPending}
>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* 预览抽屉 */}
<Drawer
title={selectedArticle?.title}
open={isDrawerOpen}
onClose={() => {
setIsDrawerOpen(false);
setSelectedArticle(null);
}}
width={600}
>
{selectedArticle && (
<div>
<div className="mb-4">
<Tag color="blue">
{CATEGORIES.find((c) => c.value === selectedArticle.category)
?.label || selectedArticle.category}
</Tag>
{selectedArticle.isPublished ? (
<Tag color="success"></Tag>
) : (
<Tag>稿</Tag>
)}
<span className="ml-2 text-gray-500 text-sm">
: {selectedArticle.qualityScore} | :{' '}
{selectedArticle.citationCount}
</span>
</div>
{selectedArticle.tags?.length > 0 && (
<div className="mb-4">
{selectedArticle.tags.map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</div>
)}
<Paragraph className="text-gray-600 mb-4">
{selectedArticle.summary}
</Paragraph>
<div className="whitespace-pre-wrap">{selectedArticle.content}</div>
</div>
)}
</Drawer>
</div>
);
}