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; content: string;
category: string; category: string;
tags?: string[]; tags?: string[];
source?: string;
} }
export interface ExtractedTextResponse { export interface ExtractedTextResponse {

View File

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

View File

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

View File

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