423 lines
12 KiB
TypeScript
423 lines
12 KiB
TypeScript
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">
|
||
支持 PDF、Word(.docx)、TXT、Markdown 格式,最大 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>
|
||
);
|
||
}
|