Enhance FileUpload component to support additional file types including PDF, TXT, MD, MP4, and AVI. Refactor file type handling logic for improved accuracy and user feedback. Update styles in AIKnowledgeDetail for better layout and user experience, including new sections for prompt rules and independent prompts.

This commit is contained in:
超级老白兔
2025-10-31 15:46:51 +08:00
parent 8d017ed2cb
commit fe347965ff
7 changed files with 901 additions and 303 deletions

View File

@@ -72,25 +72,83 @@ const FileUpload: React.FC<FileUploadProps> = ({
name: "PPT文件",
extensions: ["pptx", "ppt"],
},
pdf: {
accept: ".pdf",
mimeTypes: ["application/pdf"],
icon: FileOutlined,
name: "PDF文件",
extensions: ["pdf"],
},
txt: {
accept: ".txt",
mimeTypes: ["text/plain"],
icon: FileOutlined,
name: "文本文件",
extensions: ["txt"],
},
md: {
accept: ".md",
mimeTypes: ["text/markdown"],
icon: FileOutlined,
name: "Markdown文件",
extensions: ["md"],
},
mp4: {
accept: ".mp4",
mimeTypes: ["video/mp4"],
icon: FileOutlined,
name: "MP4视频",
extensions: ["mp4"],
},
avi: {
accept: ".avi",
mimeTypes: ["video/x-msvideo"],
icon: FileOutlined,
name: "AVI视频",
extensions: ["avi"],
},
};
// 生成accept字符串
const generateAcceptString = () => {
return acceptTypes
.map(type => fileTypeConfig[type as keyof typeof fileTypeConfig]?.accept)
.filter(Boolean)
.join(",");
const accepts: string[] = [];
for (const type of acceptTypes) {
// 如果是配置中的类型键(如 "word", "pdf"
const config = fileTypeConfig[type as keyof typeof fileTypeConfig];
if (config) {
accepts.push(config.accept);
} else {
// 如果是扩展名(如 "doc", "docx"),直接添加
accepts.push(`.${type}`);
}
}
return accepts.filter(Boolean).join(",");
};
// 获取文件类型信息
const getFileTypeInfo = (file: File) => {
const extension = file.name.split(".").pop()?.toLowerCase();
if (!extension) return null;
// 首先尝试通过 acceptTypes 中指定的类型键来查找
for (const type of acceptTypes) {
const config = fileTypeConfig[type as keyof typeof fileTypeConfig];
if (config && config.extensions.includes(extension || "")) {
if (config && config.extensions.includes(extension)) {
return config;
}
}
// 如果 acceptTypes 中包含扩展名本身(如 "doc", "docx"),查找所有包含该扩展名的配置
if (acceptTypes.includes(extension)) {
for (const [key, config] of Object.entries(fileTypeConfig)) {
if (config.extensions.includes(extension)) {
return config;
}
}
}
return null;
};
@@ -116,12 +174,29 @@ const FileUpload: React.FC<FileUploadProps> = ({
}
}, [value]);
// 获取类型名称
const getTypeName = (type: string) => {
const config = fileTypeConfig[type as keyof typeof fileTypeConfig];
if (config) return config.name;
// 如果是扩展名,返回友好的名称
const extensionNames: Record<string, string> = {
doc: "Word文件",
docx: "Word文件",
pdf: "PDF文件",
txt: "文本文件",
md: "Markdown文件",
mp4: "MP4视频",
avi: "AVI视频",
};
return extensionNames[type] || `${type.toUpperCase()}文件`;
};
// 文件验证
const beforeUpload = (file: File) => {
const typeInfo = getFileTypeInfo(file);
if (!typeInfo) {
const allowedTypes = acceptTypes
.map(type => fileTypeConfig[type as keyof typeof fileTypeConfig]?.name)
.map(type => getTypeName(type))
.filter(Boolean)
.join("、");
message.error(`只能上传${allowedTypes}`);
@@ -310,10 +385,7 @@ const FileUpload: React.FC<FileUploadProps> = ({
<div className={style.uploadSubtitle}>
{" "}
{acceptTypes
.map(
type =>
fileTypeConfig[type as keyof typeof fileTypeConfig]?.name,
)
.map(type => getTypeName(type))
.filter(Boolean)
.join("、")}
{maxSize}MB

View File

@@ -0,0 +1,13 @@
.uploadButtonWrapper {
// 使用 :global() 包装 Ant Design 的全局类名
:global {
.ant-upload-select {
// 这里可以修改 .ant-upload-select 的样式
display: block;
width: 100%;
span {
display: block;
}
}
}
}

View File

@@ -0,0 +1,282 @@
import React, { useState } from "react";
import { Upload, message, Button } from "antd";
import {
LoadingOutlined,
CloudUploadOutlined,
FileExcelOutlined,
FileWordOutlined,
FilePptOutlined,
FileOutlined,
} from "@ant-design/icons";
import type { UploadProps } from "antd/es/upload/interface";
import style from "./index.module.scss";
export interface FileUploadResult {
fileName: string; // 文件名
fileUrl: string; // 文件URL
}
interface FileUploadProps {
onChange?: (result: FileUploadResult) => void; // 上传成功后的回调返回文件名和URL
disabled?: boolean;
className?: string;
maxSize?: number; // 最大文件大小(MB)
acceptTypes?: string[]; // 接受的文件类型
buttonText?: string; // 按钮文本
buttonType?: "default" | "primary" | "dashed" | "text" | "link"; // 按钮类型
block?: boolean;
size?: "small" | "middle" | "large";
showSuccessMessage?: boolean; // 是否显示上传成功提示,默认不显示
}
const FileUpload: React.FC<FileUploadProps> = ({
onChange,
disabled = false,
className,
maxSize = 10,
acceptTypes = ["excel", "word", "ppt"],
buttonText = "上传文件",
buttonType = "primary",
block = false,
size = "middle",
showSuccessMessage = false,
}) => {
const [loading, setLoading] = useState(false);
const [fileName, setFileName] = useState<string>(""); // 保存文件名
// 文件类型配置
const fileTypeConfig = {
excel: {
accept: ".xlsx,.xls",
mimeTypes: [
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-excel",
],
icon: FileExcelOutlined,
name: "Excel文件",
extensions: ["xlsx", "xls"],
},
word: {
accept: ".docx,.doc",
mimeTypes: [
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword",
],
icon: FileWordOutlined,
name: "Word文件",
extensions: ["docx", "doc"],
},
ppt: {
accept: ".pptx,.ppt",
mimeTypes: [
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.ms-powerpoint",
],
icon: FilePptOutlined,
name: "PPT文件",
extensions: ["pptx", "ppt"],
},
pdf: {
accept: ".pdf",
mimeTypes: ["application/pdf"],
icon: FileOutlined,
name: "PDF文件",
extensions: ["pdf"],
},
txt: {
accept: ".txt",
mimeTypes: ["text/plain"],
icon: FileOutlined,
name: "文本文件",
extensions: ["txt"],
},
doc: {
accept: ".doc,.docx",
mimeTypes: [
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword",
],
icon: FileWordOutlined,
name: "Word文件",
extensions: ["doc", "docx"],
},
docx: {
accept: ".docx,.doc",
mimeTypes: [
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword",
],
icon: FileWordOutlined,
name: "Word文件",
extensions: ["docx", "doc"],
},
md: {
accept: ".md",
mimeTypes: ["text/markdown"],
icon: FileOutlined,
name: "Markdown文件",
extensions: ["md"],
},
};
// 生成accept字符串
const generateAcceptString = () => {
const accepts: string[] = [];
for (const type of acceptTypes) {
// 如果是配置中的类型键(如 "word", "pdf"
const config = fileTypeConfig[type as keyof typeof fileTypeConfig];
if (config) {
accepts.push(config.accept);
} else {
// 如果是扩展名(如 "doc", "docx"),直接添加
accepts.push(`.${type}`);
}
}
return accepts.filter(Boolean).join(",");
};
// 获取文件类型信息
const getFileTypeInfo = (file: File) => {
const extension = file.name.split(".").pop()?.toLowerCase();
if (!extension) return null;
// 首先尝试通过 acceptTypes 中指定的类型键来查找
for (const type of acceptTypes) {
const config = fileTypeConfig[type as keyof typeof fileTypeConfig];
if (config && config.extensions.includes(extension)) {
return config;
}
}
// 如果 acceptTypes 中包含扩展名本身(如 "doc", "docx"),查找所有包含该扩展名的配置
if (acceptTypes.includes(extension)) {
for (const [, config] of Object.entries(fileTypeConfig)) {
if (config.extensions.includes(extension)) {
return config;
}
}
}
return null;
};
// 获取类型名称
const getTypeName = (type: string) => {
const config = fileTypeConfig[type as keyof typeof fileTypeConfig];
if (config) return config.name;
// 如果是扩展名,返回友好的名称
const extensionNames: Record<string, string> = {
doc: "Word文件",
docx: "Word文件",
pdf: "PDF文件",
txt: "文本文件",
md: "Markdown文件",
};
return extensionNames[type] || `${type.toUpperCase()}文件`;
};
// 文件验证
const beforeUpload = (file: File) => {
// 保存文件名
setFileName(file.name);
const typeInfo = getFileTypeInfo(file);
if (!typeInfo) {
const allowedTypes = acceptTypes
.map(type => getTypeName(type))
.filter(Boolean)
.join("、");
message.error(`只能上传${allowedTypes}`);
return false;
}
const isLtMaxSize = file.size / 1024 / 1024 < maxSize;
if (!isLtMaxSize) {
message.error(`文件大小不能超过${maxSize}MB`);
return false;
}
return true;
};
// 处理文件变化
const handleChange: UploadProps["onChange"] = info => {
// 处理上传状态
if (info.file.status === "uploading") {
setLoading(true);
} else if (info.file.status === "done") {
setLoading(false);
if (showSuccessMessage) {
message.success("文件上传成功!");
}
// 从响应中获取上传后的URL
let uploadedUrl = "";
if (info.file.response) {
if (typeof info.file.response === "string") {
uploadedUrl = info.file.response;
} else if (info.file.response.data) {
uploadedUrl =
typeof info.file.response.data === "string"
? info.file.response.data
: info.file.response.data.url || "";
} else if (info.file.response.url) {
uploadedUrl = info.file.response.url;
}
}
// 获取文件名,优先使用保存的文件名,如果没有则使用文件对象的名称
const finalFileName = fileName || info.file.name || "";
if (uploadedUrl && finalFileName) {
onChange?.({
fileName: finalFileName,
fileUrl: uploadedUrl,
});
// 清空保存的文件名,为下次上传做准备
setFileName("");
}
} else if (info.file.status === "error") {
setLoading(false);
message.error("上传失败,请重试");
// 清空保存的文件名
setFileName("");
}
};
const action = import.meta.env.VITE_API_BASE_URL + "/v1/attachment/upload";
return (
<div className={style.uploadButtonWrapper}>
<Upload
name="file"
headers={{
Authorization: `Bearer ${localStorage.getItem("token")}`,
}}
action={action}
accept={generateAcceptString()}
showUploadList={false}
disabled={disabled || loading}
beforeUpload={beforeUpload}
onChange={handleChange}
>
<Button
type={buttonType}
icon={loading ? <LoadingOutlined /> : <CloudUploadOutlined />}
loading={loading}
disabled={disabled}
className={style.uploadButton}
block
size={size}
>
{buttonText}
</Button>
</Upload>
</div>
);
};
export default FileUpload;

View File

@@ -20,7 +20,7 @@ export interface Material {
type?: KnowledgeBase; // 关联的知识库类型信息
// 前端扩展字段
fileName?: string; // 映射自 name
fileSize?: number; // 文件大小(前端计算)
size?: number; // 文件大小(前端计算)
fileType?: string; // 文件类型(从 name 提取)
filePath?: string; // 映射自 fileUrl
tags?: string[]; // 映射自 label

View File

@@ -4,6 +4,49 @@
min-height: 100vh;
}
.detailContent {
padding: 16px;
}
// 提示横幅
.banner {
background: linear-gradient(135deg, #e6f7ff 0%, #f0f5ff 100%);
border-radius: 12px;
padding: 12px 16px;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 12px;
border: 1px solid #91d5ff;
}
.bannerIcon {
font-size: 20px;
color: #1890ff;
flex-shrink: 0;
}
.bannerContent {
flex: 1;
}
.bannerText {
font-size: 14px;
color: #333;
line-height: 1.5;
a {
color: #1890ff;
text-decoration: none;
font-weight: 500;
cursor: pointer;
&:active {
opacity: 0.7;
}
}
}
// Tab容器
.tabContainer {
background: #fff;
@@ -42,7 +85,6 @@
// 知识库信息卡片
.infoCard {
background: #fff;
margin: 12px 16px;
border-radius: 12px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
@@ -78,6 +120,15 @@
font-weight: 600;
color: #222;
margin-bottom: 6px;
display: flex;
align-items: center;
}
.systemPresetLabel {
margin-left: 8px;
font-size: 12px;
color: #999;
font-weight: normal;
}
.infoDescription {
@@ -127,6 +178,66 @@
font-weight: 500;
}
// 提示词生效规则
.promptRulesSection {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.rulesList {
margin: 12px 0 0 0;
padding-left: 20px;
font-size: 13px;
color: #666;
line-height: 1.8;
li {
margin-bottom: 8px;
}
}
// 知识库独立提示词
.independentPromptSection {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.promptDisplay {
background: #f9f9f9;
border-radius: 8px;
padding: 12px;
margin-top: 8px;
min-height: 80px;
max-height: 200px;
overflow-y: auto;
}
.promptText {
font-size: 13px;
color: #333;
line-height: 1.6;
white-space: pre-wrap;
}
.promptEmpty {
font-size: 13px;
color: #999;
text-align: center;
padding: 20px 0;
}
// 上传素材区域
.uploadSection {
margin: 16px 0;
}
// 库内素材区域
.materialsSection {
margin-top: 16px;
}
.tags {
display: flex;
flex-wrap: wrap;
@@ -226,10 +337,16 @@
color: #333;
}
.sectionIcon {
font-size: 16px;
color: #1890ff;
}
.sectionCount {
font-size: 13px;
color: #888;
font-weight: normal;
margin-left: 4px;
}
.callerList {
@@ -257,6 +374,22 @@
height: 40px;
border-radius: 50%;
flex-shrink: 0;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.anticon {
font-size: 20px;
color: #999;
}
}
.callerInfo {
@@ -405,34 +538,16 @@
.bottomActions {
display: flex;
gap: 12px;
padding: 16px;
background: #fff;
border-top: 1px solid #f0f0f0;
}
.actionButton {
flex: 1;
padding: 12px;
border: 1px solid #d9d9d9;
border-radius: 8px;
background: #fff;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
&:active {
opacity: 0.7;
}
padding: 16px 0;
margin-top: 16px;
}
.editButton {
color: #1890ff;
border-color: #1890ff;
flex: 1;
}
.deleteButton {
color: #ff4d4f;
border-color: #ff4d4f;
flex: 1;
}
// 空状态

View File

@@ -1,20 +1,10 @@
import React, { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Button,
Switch,
message,
Spin,
Dropdown,
Modal,
Input,
Upload,
} from "antd";
import { Button, Switch, message, Spin, Dropdown, Modal } from "antd";
import { Dialog, Toast } from "antd-mobile";
import {
BookOutlined,
CheckCircleOutlined,
UserOutlined,
UploadOutlined,
FileOutlined,
VideoCameraOutlined,
FileTextOutlined,
@@ -23,9 +13,11 @@ import {
DeleteOutlined,
SettingOutlined,
ApiOutlined,
BulbOutlined,
CalendarOutlined,
DatabaseOutlined,
CheckCircleOutlined,
GlobalOutlined,
PlusOutlined,
InfoCircleOutlined,
MessageOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
@@ -39,14 +31,12 @@ import {
} from "./api";
import { deleteKnowledgeBase } from "../list/api";
import type { KnowledgeBase, Material, Caller } from "./data";
import FileUpload from "@/components/Upload/FileUpload";
type TabType = "info" | "materials";
import FileUpload from "@/components/Upload/FileUploadButton";
import GlobalPromptModal from "../list/components/GlobalPromptModal";
const AIKnowledgeDetail: React.FC = () => {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const [activeTab, setActiveTab] = useState<TabType>("info");
const [loading, setLoading] = useState(false);
const [knowledgeBase, setKnowledgeBase] = useState<KnowledgeBase | null>(
null,
@@ -55,6 +45,7 @@ const AIKnowledgeDetail: React.FC = () => {
const [callers, setCallers] = useState<Caller[]>([]);
const [promptEditVisible, setPromptEditVisible] = useState(false);
const [independentPrompt, setIndependentPrompt] = useState("");
const [globalPromptVisible, setGlobalPromptVisible] = useState(false);
useEffect(() => {
if (id) {
@@ -111,6 +102,46 @@ const AIKnowledgeDetail: React.FC = () => {
}
};
// 更新素材列表
const fetchMaterialList = async () => {
if (!id) return;
try {
const materialRes = await getMaterialList({
knowledgeBaseId: Number(id),
page: 1,
limit: 100,
});
// 转换素材数据格式
const transformedMaterials = (materialRes.list || []).map(
(item: any) => ({
...item,
fileName: item.name,
tags: item.label || [],
filePath: item.fileUrl,
uploadTime: item.createTime
? new Date(item.createTime * 1000).toLocaleDateString("zh-CN")
: "-",
uploaderId: item.userId,
fileType: item.name?.split(".").pop() || "file",
fileSize: 0, // 接口未返回,需要前端计算或后端补充
}),
);
setMaterials(transformedMaterials);
// 更新知识库的素材数量
if (knowledgeBase) {
setKnowledgeBase({
...knowledgeBase,
materialCount: transformedMaterials.length,
});
}
} catch (error) {
console.error("获取素材列表失败", error);
}
};
const handleAICallToggle = async (checked: boolean) => {
if (!id || !knowledgeBase) return;
@@ -139,46 +170,6 @@ const AIKnowledgeDetail: React.FC = () => {
}
};
const handleIndependentPromptToggle = async (checked: boolean) => {
if (!id || !knowledgeBase) return;
// 系统预设不允许修改
if (knowledgeBase.type === 0) {
message.warning("系统预设知识库不可修改");
return;
}
if (checked) {
// 启用时打开编辑弹窗
setPromptEditVisible(true);
} else {
// 禁用时直接更新
try {
await updateKnowledgeBaseConfig({
id: Number(id),
name: knowledgeBase.name,
description: knowledgeBase.description,
label: knowledgeBase.tags || knowledgeBase.label || [],
useIndependentPrompt: false,
independentPrompt: "",
});
message.success("已关闭独立提示词");
setKnowledgeBase(prev =>
prev
? {
...prev,
useIndependentPrompt: false,
independentPrompt: "",
prompt: null,
}
: null,
);
} catch (error) {
message.error("操作失败");
}
}
};
const handlePromptSave = async () => {
if (!id || !knowledgeBase) return;
if (!independentPrompt.trim()) {
@@ -221,41 +212,52 @@ const AIKnowledgeDetail: React.FC = () => {
return;
}
Modal.confirm({
title: "确认删除",
const result = await Dialog.confirm({
content: "删除后数据无法恢复,确定要删除该知识库吗?",
okText: "确定",
confirmText: "确定",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: async () => {
try {
await deleteKnowledgeBase(Number(id));
message.success("删除成功");
navigate(-1);
} catch (error) {
message.error("删除失败");
}
},
});
if (result) {
try {
await deleteKnowledgeBase(Number(id));
Toast.show({
content: "删除成功",
icon: "success",
});
// 刷新素材列表
await fetchMaterialList();
} catch (error) {
Toast.show({
content: "删除失败",
icon: "fail",
});
}
}
};
const handleDeleteMaterial = async (materialId: number) => {
Modal.confirm({
title: "确认删除",
const result = await Dialog.confirm({
content: "确定要删除该素材吗?",
okText: "确定",
confirmText: "确定",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: async () => {
try {
await deleteMaterial(materialId);
message.success("删除成功");
setMaterials(prev => prev.filter(m => m.id !== materialId));
} catch (error) {
message.error("删除失败");
}
},
});
if (result) {
try {
await deleteMaterial(materialId);
Toast.show({
content: "删除成功",
icon: "success",
});
setMaterials(prev => prev.filter(m => m.id !== materialId));
} catch (error) {
Toast.show({
content: "删除失败",
icon: "fail",
});
}
}
};
const handleUpload = async (file: File) => {
@@ -318,13 +320,27 @@ const AIKnowledgeDetail: React.FC = () => {
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
};
const renderInfoTab = () => {
const renderContent = () => {
if (!knowledgeBase) return null;
const isSystemPreset = knowledgeBase.type === 0; // 系统预设只读
const isSystemPreset = knowledgeBase.type === 0;
return (
<>
<div className={style.detailContent}>
{/* 提示横幅 */}
<div className={style.banner}>
<InfoCircleOutlined className={style.bannerIcon} />
<div className={style.bannerContent}>
<div className={style.bannerText}>
·{" "}
<a onClick={() => setGlobalPromptVisible(true)}>
&#34;&#34;
</a>
</div>
</div>
</div>
{/* 知识库信息卡片 */}
<div className={style.infoCard}>
<div className={style.infoHeader}>
<div className={style.infoIcon}>
@@ -377,9 +393,10 @@ const AIKnowledgeDetail: React.FC = () => {
</div>
</div>
{/* 内容标签 */}
{knowledgeBase.tags && knowledgeBase.tags.length > 0 && (
<div className={style.infoTags}>
<div className={style.tagTitle}></div>
<div className={style.tagTitle}></div>
<div className={style.tags}>
{knowledgeBase.tags.map((tag, index) => (
<span key={index} className={style.tag}>
@@ -397,9 +414,6 @@ const AIKnowledgeDetail: React.FC = () => {
<ApiOutlined className={style.configIcon} />
AI调用配置
</div>
<div className={style.configDescription}>
AI助手可以使用此内容库的素材
</div>
</div>
<Switch
checked={knowledgeBase.aiCallEnabled}
@@ -411,7 +425,15 @@ const AIKnowledgeDetail: React.FC = () => {
<div className={style.configItem}>
<div>
<div className={style.configLabel}>
<BulbOutlined className={style.configIcon} />
<CheckCircleOutlined className={style.configIcon} />
AI助手可以使用此内容库的素材
</div>
</div>
</div>
<div className={style.configItem}>
<div>
<div className={style.configLabel}>
<CheckCircleOutlined className={style.configIcon} />
</div>
</div>
@@ -420,47 +442,90 @@ const AIKnowledgeDetail: React.FC = () => {
<div className={style.configItem}>
<div>
<div className={style.configLabel}>
<SettingOutlined className={style.configIcon} />
<CheckCircleOutlined className={style.configIcon} />
</div>
</div>
</div>
</div>
<div className={style.featureList}>
<div className={style.featureItem}>
<CheckCircleOutlined className={style.featureIcon} />
</div>
<div className={style.featureItem}>
<CheckCircleOutlined className={style.featureIcon} />
{/* 提示词生效规则 */}
<div className={style.promptRulesSection}>
<div className={style.sectionHeader}>
<div className={style.sectionTitle}>
<InfoCircleOutlined className={style.sectionIcon} />
</div>
</div>
<ol className={style.rulesList}>
<li>1 ()</li>
<li>2 ()</li>
<li>3</li>
</ol>
</div>
{/* 知识库独立提示词 */}
<div className={style.independentPromptSection}>
<div className={style.sectionHeader}>
<div className={style.sectionTitle}>
<MessageOutlined className={style.sectionIcon} />
</div>
<Button
type="text"
size="small"
icon={<SettingOutlined />}
></Button>
</div>
<div className={style.promptDisplay}>
{knowledgeBase.independentPrompt || independentPrompt ? (
<div className={style.promptText}>
{knowledgeBase.independentPrompt || independentPrompt}
</div>
) : (
<div className={style.promptEmpty}>
</div>
)}
</div>
<Button
type="primary"
ghost
block={true}
onClick={() => setPromptEditVisible(true)}
disabled={isSystemPreset}
style={{ marginTop: 8 }}
>
</Button>
</div>
{/* 调用客服名单 */}
{callers.length > 0 && (
<div className={style.callerSection}>
<div className={style.sectionHeader}>
<div className={style.sectionTitle}>
<UserOutlined />
<UserOutlined className={style.sectionIcon} />
<span className={style.sectionCount}>{callers.length}</span>
</div>
</div>
<div className={style.callerList}>
{callers.slice(0, 3).map(caller => (
<div key={caller.id} className={style.callerItem}>
<img
src={caller.avatar}
alt={caller.name}
className={style.callerAvatar}
/>
<div className={style.callerAvatar}>
{caller.avatar ? (
<img src={caller.avatar} alt={caller.name} />
) : (
<UserOutlined />
)}
</div>
<div className={style.callerInfo}>
<div className={style.callerName}>{caller.name}</div>
<div className={style.callerRole}>{caller.role}</div>
</div>
<div className={style.callerTime}>
{caller.callCount} · {caller.lastCallTime}
{caller.callCount} · {caller.lastCallTime}
</div>
</div>
))}
@@ -469,52 +534,26 @@ const AIKnowledgeDetail: React.FC = () => {
)}
</div>
{/* 系统预设不显示编辑和删除按钮 */}
{/* 上传素材按钮 */}
{!isSystemPreset && (
<div className={style.bottomActions}>
<button
className={`${style.actionButton} ${style.editButton}`}
onClick={() => navigate(`/workspace/ai-knowledge/${id}/edit`)}
>
<EditOutlined />
</button>
<button
className={`${style.actionButton} ${style.deleteButton}`}
onClick={handleDeleteKnowledge}
>
<DeleteOutlined />
</button>
</div>
)}
</>
);
};
const renderMaterialsTab = () => {
const isSystemPreset = knowledgeBase?.type === 0; // 系统预设只读
return (
<div className={style.materialSection}>
{/* 系统预设不显示上传按钮 */}
{!isSystemPreset && (
<div className={style.uploadButtonWrap}>
<div className={style.uploadSection}>
<FileUpload
maxCount={1}
disabled={isSystemPreset}
buttonText="上传素材到此库"
acceptTypes={["pdf", "txt", "doc", "docx", "md"]}
onChange={async v => {
// v 可能为 string 或 string[]:单传取 string
const uploadUrl = Array.isArray(v) ? v[0] : v;
if (uploadUrl && id) {
block={true}
size="large"
onChange={async result => {
if (result && result.fileUrl && result.fileName && id) {
try {
await uploadMaterial({
typeId: Number(id),
name: uploadUrl.split("/").pop() || "已上传文件",
name: result.fileName,
label: [],
fileUrl: uploadUrl,
fileUrl: result.fileUrl,
});
message.success("上传成功");
fetchDetail();
// 更新素材列表
await fetchMaterialList();
} catch (e) {
message.error("上传失败");
}
@@ -523,68 +562,94 @@ const AIKnowledgeDetail: React.FC = () => {
/>
</div>
)}
<div className={style.materialList}>
{materials.length > 0 ? (
materials.map(material => (
<div key={material.id} className={style.materialItem}>
{getFileIcon(material.fileType)}
<div className={style.materialContent}>
<div className={style.materialHeader}>
<div className={style.materialName}>
{material.fileName}
{/* 库内素材 */}
<div className={style.materialsSection}>
<div className={style.sectionHeader}>
<div className={style.sectionTitle}>
<span className={style.sectionCount}>{materials.length}</span>
</div>
</div>
<div className={style.materialList}>
{materials.length > 0 ? (
materials.map(material => (
<div key={material.id} className={style.materialItem}>
{getFileIcon(material.fileType)}
<div className={style.materialContent}>
<div className={style.materialHeader}>
<div className={style.materialName}>
{material.fileName}
</div>
{!isSystemPreset && (
<Dropdown
menu={{
items: [
{
key: "delete",
icon: <DeleteOutlined />,
label: "删除",
danger: true,
},
],
onClick: () => handleDeleteMaterial(material.id),
}}
trigger={["click"]}
placement="bottomRight"
>
<MoreOutlined className={style.materialMenu} />
</Dropdown>
)}
</div>
{/* 系统预设不显示删除按钮 */}
{!isSystemPreset && (
<Dropdown
menu={{
items: [
{
key: "delete",
icon: <DeleteOutlined />,
label: "删除",
danger: true,
},
],
onClick: () => handleDeleteMaterial(material.id),
}}
trigger={["click"]}
placement="bottomRight"
>
<MoreOutlined className={style.materialMenu} />
</Dropdown>
<div className={style.materialMeta}>
<div className={style.materialSize}>
{formatFileSize(material?.size || 0)}
</div>
<div className={style.materialDate}>
{material.uploadTime}
</div>
</div>
{material.tags && material.tags.length > 0 && (
<div className={style.materialTags}>
{material.tags.map((tag, index) => (
<span key={index} className={style.materialTag}>
{tag}
</span>
))}
</div>
)}
</div>
<div className={style.materialMeta}>
<div className={style.materialSize}>
<DatabaseOutlined />
{formatFileSize(material.fileSize)}
</div>
<div className={style.materialDate}>
<CalendarOutlined />
{material.uploadTime}
</div>
</div>
{material.tags && material.tags.length > 0 && (
<div className={style.materialTags}>
{material.tags.map((tag, index) => (
<span key={index} className={style.materialTag}>
{tag}
</span>
))}
</div>
)}
</div>
))
) : (
<div className={style.empty}>
<div className={style.emptyIcon}>
<FileOutlined />
</div>
<div className={style.emptyText}></div>
</div>
))
) : (
<div className={style.empty}>
<div className={style.emptyIcon}>
<FileOutlined />
</div>
<div className={style.emptyText}></div>
</div>
)}
)}
</div>
</div>
{/* 底部操作按钮 */}
{!isSystemPreset && (
<div className={style.bottomActions}>
<Button
className={style.editButton}
onClick={() => navigate(`/workspace/ai-knowledge/${id}/edit`)}
>
<EditOutlined />
</Button>
<Button
danger
className={style.deleteButton}
onClick={handleDeleteKnowledge}
>
<DeleteOutlined />
</Button>
</div>
)}
</div>
);
};
@@ -596,23 +661,20 @@ const AIKnowledgeDetail: React.FC = () => {
<NavCommon
title="AI知识库"
backFn={() => navigate("/workspace/ai-knowledge")}
right={
<div style={{ display: "flex", gap: 8 }}>
<Button onClick={() => setGlobalPromptVisible(true)}>
<GlobalOutlined />
</Button>
<Button
type="primary"
onClick={() => navigate("/workspace/ai-knowledge/new")}
>
<PlusOutlined />
</Button>
</div>
}
/>
<div className={style.tabContainer}>
<div className={style.tabs}>
<button
className={`${style.tab} ${activeTab === "info" ? style.tabActive : ""}`}
onClick={() => setActiveTab("info")}
>
</button>
<button
className={`${style.tab} ${activeTab === "materials" ? style.tabActive : ""}`}
onClick={() => setActiveTab("materials")}
>
</button>
</div>
</div>
</>
}
>
@@ -622,10 +684,7 @@ const AIKnowledgeDetail: React.FC = () => {
<Spin size="large" />
</div>
) : (
<>
{activeTab === "info" && renderMaterialsTab()}
{activeTab === "materials" && renderInfoTab()}
</>
renderContent()
)}
</div>
@@ -650,6 +709,12 @@ const AIKnowledgeDetail: React.FC = () => {
💡 使
</div>
</Modal>
{/* 统一提示词弹窗 */}
<GlobalPromptModal
visible={globalPromptVisible}
onClose={() => setGlobalPromptVisible(false)}
/>
</Layout>
);
};

View File

@@ -1,6 +1,14 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Switch, Dropdown, message, Spin, Pagination } from "antd";
import {
Button,
Switch,
Dropdown,
message,
Spin,
Pagination,
Input,
} from "antd";
import {
MoreOutlined,
PlusOutlined,
@@ -9,6 +17,8 @@ import {
DeleteOutlined,
GlobalOutlined,
InfoCircleOutlined,
SearchOutlined,
ArrowRightOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
@@ -32,6 +42,7 @@ const AIKnowledgeList: React.FC = () => {
const [enabledCount, setEnabledCount] = useState(0);
const [page, setPage] = useState(1);
const [menuLoadingId, setMenuLoadingId] = useState<number | null>(null);
const [searchValue, setSearchValue] = useState(""); // 搜索内容
// 弹窗控制
const [globalPromptVisible, setGlobalPromptVisible] = useState(false);
@@ -80,6 +91,21 @@ const AIKnowledgeList: React.FC = () => {
fetchList(page);
};
const handleSearch = () => {
setPage(1);
};
const getFilteredList = () => {
const keyword = searchValue.trim().toLowerCase();
if (!keyword) return list;
return list.filter(item => {
return (
item.name.toLowerCase().includes(keyword) ||
(item.description || "").toLowerCase().includes(keyword)
);
});
};
// 菜单点击事件
const handleMenuClick = async (key: string, item: KnowledgeBase) => {
// 系统预设不允许编辑或删除
@@ -257,23 +283,75 @@ const AIKnowledgeList: React.FC = () => {
return (
<Layout
header={
<NavCommon
title="AI知识库"
backFn={() => navigate("/workspace")}
right={
<div style={{ display: "flex", gap: 8 }}>
<Button onClick={() => setGlobalPromptVisible(true)}>
<GlobalOutlined />
</Button>
<Button
type="primary"
onClick={() => navigate("/workspace/ai-knowledge/new")}
>
<PlusOutlined />
</Button>
<>
<NavCommon
title="AI知识库"
backFn={() => navigate("/workspace")}
right={
<div style={{ display: "flex", gap: 8 }}>
<Button onClick={() => setGlobalPromptVisible(true)}>
<GlobalOutlined />
</Button>
<Button
type="primary"
onClick={() => navigate("/workspace/ai-knowledge/new")}
>
<PlusOutlined />
</Button>
</div>
}
/>
<div
style={{
padding: "16px 16px 0 16px",
}}
>
{/* 提示横幅 */}
<div className={style.banner}>
<InfoCircleOutlined className={style.bannerIcon} />
<div className={style.bannerContent}>
<div className={style.bannerText}>
<a onClick={() => setGlobalPromptVisible(true)}>
&ldquo;&rdquo;
</a>
</div>
</div>
</div>
}
/>
{/* 统计卡片 */}
<div className={style.statsContainer}>
<div className={style.statCard}>
<div className={style.statValue}>{total}</div>
<div className={style.statLabel}></div>
</div>
<div className={style.statCard}>
<div className={`${style.statValue} ${style.statValueSuccess}`}>
{enabledCount}
</div>
<div className={style.statLabel}></div>
</div>
</div>
{/* 搜索和客户案例库按钮 */}
<div
style={{
display: "flex",
gap: 8,
marginBottom: 12,
}}
>
<Input
placeholder="搜索计划名称"
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
prefix={<SearchOutlined />}
allowClear
size="large"
/>
</div>
</div>
</>
}
footer={
<div
@@ -299,40 +377,13 @@ const AIKnowledgeList: React.FC = () => {
}
>
<div className={style.knowledgePage}>
{/* 提示横幅 */}
<div className={style.banner}>
<InfoCircleOutlined className={style.bannerIcon} />
<div className={style.bannerContent}>
<div className={style.bannerText}>
·{" "}
<a onClick={() => setGlobalPromptVisible(true)}>
&ldquo;&rdquo;
</a>
</div>
</div>
</div>
{/* 统计卡片 */}
<div className={style.statsContainer}>
<div className={style.statCard}>
<div className={style.statValue}>{total}</div>
<div className={style.statLabel}></div>
</div>
<div className={style.statCard}>
<div className={`${style.statValue} ${style.statValueSuccess}`}>
{enabledCount}
</div>
<div className={style.statLabel}></div>
</div>
</div>
{/* 知识库列表 */}
{loading ? (
<div style={{ textAlign: "center", padding: "40px 0" }}>
<Spin />
</div>
) : list.length > 0 ? (
list.map(renderCard)
) : getFilteredList().length > 0 ? (
getFilteredList().map(renderCard)
) : (
<div className={style.empty}>
<div className={style.emptyIcon}>