refactor(knowledge): separate file upload into independent entry point

将知识库的"新建文章"和"上传文件"拆分为两个独立入口:

UI 改动:
- 移除 Segmented 切换器,"新建文章"弹窗恢复为纯手动输入
- 新增独立的"上传文件"按钮 + 上传弹窗(Upload.Dragger)
- 上传提取完成后自动打开"确认提取内容"弹窗,预填标题+内容
- 管理员编辑确认后保存,文章来源标记为 EXTRACT

后端改动:
- CreateArticleDto 新增可选 source 字段
- Controller 使用 dto.source || MANUAL(不再硬编码 MANUAL)

流程:
- 新建文章 → 手动输入 → source = MANUAL
- 上传文件 → 提取文本 → 编辑确认 → source = EXTRACT

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-06 23:29:37 -08:00
parent fc9e0cd17b
commit 93ed3343de
4 changed files with 79 additions and 62 deletions

View File

@ -24,6 +24,7 @@ export interface CreateArticleParams {
content: string;
category: string;
tags?: string[];
source?: string;
}
export interface ExtractedTextResponse {

View File

@ -13,7 +13,6 @@ import {
Typography,
Drawer,
Upload,
Segmented,
message,
} from 'antd';
import {
@ -55,10 +54,11 @@ export function KnowledgePage() {
const [searchText, setSearchText] = useState('');
const [categoryFilter, setCategoryFilter] = useState<string>();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isUploadModalOpen, setIsUploadModalOpen] = 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 [articleSource, setArticleSource] = useState<'MANUAL' | 'EXTRACT'>('MANUAL');
const [form] = Form.useForm();
const { data, isLoading } = useKnowledgeArticles(categoryFilter);
@ -98,10 +98,11 @@ export function KnowledgePage() {
}
);
} else {
createMutation.mutate(values, {
createMutation.mutate({ ...values, source: articleSource }, {
onSuccess: () => {
setIsModalOpen(false);
form.resetFields();
setArticleSource('MANUAL');
},
});
}
@ -111,19 +112,24 @@ export function KnowledgePage() {
setIsExtracting(true);
uploadMutation.mutate(file, {
onSuccess: (result) => {
// 关闭上传弹窗,打开文章编辑弹窗(预填提取内容)
setIsUploadModalOpen(false);
setSelectedArticle(null);
setArticleSource('EXTRACT');
form.resetFields();
form.setFieldsValue({
title: result.suggestedTitle,
content: result.extractedText,
});
setIsModalOpen(true);
const info = result.pageCount
? `已提取 ${result.wordCount} 字(${result.pageCount} 页)`
: `已提取 ${result.wordCount}`;
? `已提取 ${result.wordCount} 字(${result.pageCount} 页),请编辑后保存`
: `已提取 ${result.wordCount},请编辑后保存`;
message.success(info);
setInputMode('manual');
},
onSettled: () => setIsExtracting(false),
});
return false; // prevent default upload
return false;
};
const columns = [
@ -256,18 +262,26 @@ export function KnowledgePage() {
options={CATEGORIES}
/>
</Space>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setSelectedArticle(null);
form.resetFields();
setInputMode('manual');
setIsModalOpen(true);
}}
>
</Button>
<Space>
<Button
icon={<UploadOutlined />}
onClick={() => setIsUploadModalOpen(true)}
>
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setSelectedArticle(null);
setArticleSource('MANUAL');
form.resetFields();
setIsModalOpen(true);
}}
>
</Button>
</Space>
</div>
<Table
@ -283,13 +297,48 @@ export function KnowledgePage() {
/>
</Card>
{/* 上传文件弹窗 */}
<Modal
title="上传文件"
open={isUploadModalOpen}
onCancel={() => {
if (!isExtracting) setIsUploadModalOpen(false);
}}
footer={null}
width={520}
>
<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>
</Modal>
{/* 编辑/新建弹窗 */}
<Modal
title={selectedArticle ? '编辑文章' : '新建文章'}
title={
selectedArticle
? '编辑文章'
: articleSource === 'EXTRACT'
? '确认提取内容'
: '新建文章'
}
open={isModalOpen}
onCancel={() => {
setIsModalOpen(false);
setSelectedArticle(null);
setArticleSource('MANUAL');
form.resetFields();
}}
footer={null}
@ -316,47 +365,13 @@ export function KnowledgePage() {
<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
name="content"
label="内容"
rules={[{ required: true, message: '请输入内容' }]}
>
<TextArea rows={12} placeholder="支持Markdown格式" />
</Form.Item>
<Form.Item className="mb-0 text-right">
<Space>

View File

@ -44,7 +44,7 @@ export class KnowledgeController {
async createArticle(@Body() dto: CreateArticleDto) {
const article = await this.knowledgeService.createArticle({
...dto,
source: KnowledgeSource.MANUAL,
source: dto.source || KnowledgeSource.MANUAL,
});
return {
success: true,

View File

@ -5,6 +5,7 @@ export class CreateArticleDto {
content: string;
category: string;
tags?: string[];
source?: KnowledgeSource;
sourceUrl?: string;
autoPublish?: boolean;
}