Merge branch 'PingChuan' into shiy-login

This commit is contained in:
2026-04-14 09:47:24 +08:00
31 changed files with 407 additions and 304 deletions
+2
View File
@@ -20,3 +20,5 @@ auth_doc/
teach/ teach/
typecheck_result.txt typecheck_result.txt
*.DS_Store *.DS_Store
CLAUDE.md
+5
View File
@@ -159,6 +159,11 @@ export const difyClient = {
return { result: 'success' }; return { result: 'success' };
} catch (error: any) { } catch (error: any) {
// 权限不足等明确错误需要抛出,不能吞掉
if (error.message?.includes('403') || error.message?.includes('401')) {
throw error;
}
// 网络超时等不确定错误才降级为成功(Dify 可能已执行删除)
console.warn('[Dify Chat] 删除会话请求失败,但可能已成功删除:', error.message); console.warn('[Dify Chat] 删除会话请求失败,但可能已成功删除:', error.message);
return { result: 'success' }; return { result: 'success' };
} }
+8 -3
View File
@@ -241,9 +241,14 @@ export async function deleteConversation(id: string): Promise<{ result: string }
* console.log('开场白:', opening_statement); * console.log('开场白:', opening_statement);
* ``` * ```
*/ */
export async function fetchAppParams(): Promise<AppParametersResponse> { export async function fetchAppParams(appId?: string): Promise<AppParametersResponse> {
const url = `${API_URL}/parameters`; const params = new URLSearchParams();
console.log('⚙️ [Dify Client] 获取应用参数:', { url }); if (appId) {
params.append('app_id', appId);
}
const url = params.toString() ? `${API_URL}/parameters?${params}` : `${API_URL}/parameters`;
console.log('⚙️ [Dify Client] 获取应用参数:', { url, appId });
try { try {
const response = await axios.get<AppParametersResponse>(url, { const response = await axios.get<AppParametersResponse>(url, {
+1
View File
@@ -365,6 +365,7 @@ export interface MessageEnd {
metadata?: { metadata?: {
annotation_reply?: any; annotation_reply?: any;
retriever_resources?: RetrieverResource[]; retriever_resources?: RetrieverResource[];
suggested_questions?: string[];
usage?: { usage?: {
prompt_tokens: number; prompt_tokens: number;
completion_tokens: number; completion_tokens: number;
-1
View File
@@ -55,7 +55,6 @@ export interface AreasResponse {
export interface CreateDatasetRequest { export interface CreateDatasetRequest {
area: string; area: string;
dataset_id: string;
dataset_name: string; dataset_name: string;
dataset_description?: string; dataset_description?: string;
is_default?: boolean; is_default?: boolean;
+45 -16
View File
@@ -13,6 +13,7 @@ interface ChatMessageProps {
onFeedback?: (messageId: string, feedback: Feedbacktype) => void; onFeedback?: (messageId: string, feedback: Feedbacktype) => void;
isResponding?: boolean; isResponding?: boolean;
onRegenerate?: (messageId: string) => void; onRegenerate?: (messageId: string) => void;
onSuggestedQuestionClick?: (question: string) => void;
} }
/** /**
@@ -22,7 +23,8 @@ export default function ChatMessage({
message, message,
onFeedback, onFeedback,
isResponding = false, isResponding = false,
onRegenerate onRegenerate,
onSuggestedQuestionClick,
}: ChatMessageProps) { }: ChatMessageProps) {
const [feedback, setFeedback] = useState<'like' | 'dislike' | null>( const [feedback, setFeedback] = useState<'like' | 'dislike' | null>(
message.feedback?.rating || null message.feedback?.rating || null
@@ -124,28 +126,51 @@ export default function ChatMessage({
}; };
/** /**
* 渲染建议问题 * 渲染建议问题(继续探索)
*/ */
const renderSuggestedQuestions = () => { const renderSuggestedQuestions = () => {
if (!suggestedQuestions || suggestedQuestions.length === 0) return null; if (!suggestedQuestions || suggestedQuestions.length === 0) return null;
return ( return (
<div className="suggested-questions"> <div className="suggested-questions" style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid #f0f0f0' }}>
<div className="text-sm text-gray-500 mb-2"></div> <div style={{ fontSize: 11, color: '#8c8c8c', marginBottom: 8, display: 'flex', alignItems: 'center', gap: 4 }}>
<div className="flex flex-wrap gap-2"> <i className="ri-compass-3-line" />
<span></span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{suggestedQuestions.map((question, index) => ( {suggestedQuestions.map((question, index) => (
<Button <button
key={index} key={index}
size="small" onClick={() => onSuggestedQuestionClick?.(question)}
type="dashed" style={{
className="question-button text-left" display: 'flex',
onClick={() => { alignItems: 'center',
// 这里可以添加点击建议问题的处理逻辑 gap: 8,
// console.log('Suggested question clicked:', question); padding: '6px 10px',
border: '1px solid #e8e8e8',
borderRadius: 6,
background: '#fafafa',
cursor: 'pointer',
textAlign: 'left',
fontSize: 13,
color: '#595959',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#00684a';
e.currentTarget.style.color = '#00684a';
e.currentTarget.style.background = 'rgba(0,104,74,0.04)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#e8e8e8';
e.currentTarget.style.color = '#595959';
e.currentTarget.style.background = '#fafafa';
}} }}
> >
{question} <i className="ri-search-line" style={{ color: '#8c8c8c', flexShrink: 0, fontSize: 12 }} />
</Button> <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{question}</span>
<i className="ri-arrow-right-line" style={{ color: '#bfbfbf', flexShrink: 0, fontSize: 12 }} />
</button>
))} ))}
</div> </div>
</div> </div>
@@ -198,10 +223,14 @@ export default function ChatMessage({
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
{/* 消息内容 */} {/* 消息内容 */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{isAnswer ? renderAnswerContent() : ( {isAnswer ? (
<>
{renderAnswerContent()}
{!isResponding && renderSuggestedQuestions()}
</>
) : (
<div> <div>
<Markdown content={content} /> <Markdown content={content} />
{/* {renderImages(message_files)} */}
</div> </div>
)} )}
</div> </div>
+63 -30
View File
@@ -1,5 +1,5 @@
import { useBoolean, useGetState } from 'ahooks'; import { useBoolean, useGetState } from 'ahooks';
import { Layout, theme } from 'antd'; import { Layout } from 'antd';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import ChatInput from './chat-input'; import ChatInput from './chat-input';
import ChatMessage from './chat-message'; import ChatMessage from './chat-message';
@@ -8,6 +8,7 @@ import ChatSidebar, { type ChatSidebarRef } from './sidebar';
import type { ChatItem, ConversationItem } from '~/api/dify-chat'; import type { ChatItem, ConversationItem } from '~/api/dify-chat';
import { fetchAppParams, fetchChatList, fetchConversations } from '~/api/dify-chat'; import { fetchAppParams, fetchChatList, fetchConversations } from '~/api/dify-chat';
import { CHAT_CONFIG } from '../../config/chat'; import { CHAT_CONFIG } from '../../config/chat';
import { usePermission } from '~/hooks/usePermission';
import useChatMessage from '../../hooks/use-chat-message'; import useChatMessage from '../../hooks/use-chat-message';
import useConversation from '../../hooks/use-conversation'; import useConversation from '../../hooks/use-conversation';
import { useChatApps } from '../../hooks/dify-chat-apps/useChatApps'; import { useChatApps } from '../../hooks/dify-chat-apps/useChatApps';
@@ -28,29 +29,34 @@ interface ChatTheme {
} }
/** /**
* 获取主题token - 避免在SSR环境中调用 * 主题配置常量
* 避免 SSR 环境下调用 theme.useToken() 导致 CSS-in-JS 注入报错
*/ */
function useChatTheme(): ChatTheme { const CHAT_THEME: ChatTheme = {
// Ant Design的theme.useToken()必须在组件顶层调用,不能放在useEffect中 colorBgContainer: '#ffffff',
const antdToken = typeof window !== 'undefined' ? theme.useToken().token : null; borderRadiusLG: 8,
};
return {
colorBgContainer: antdToken?.colorBgContainer || '#ffffff',
borderRadiusLG: antdToken?.borderRadiusLG || 8,
};
}
/** /**
* 主聊天组件 * 主聊天组件
* 实现单页面应用模式,参考webapp-conversation的初始化逻辑 * 实现单页面应用模式,参考webapp-conversation的初始化逻辑
*/ */
export default function Chat() { export default function Chat() {
// SSR 兼容:Ant Design 的 CSS-in-JS 在 Remix SSR 环境下
// hydration 时会因找不到样式容器而报错,需要客户端渲染
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
// 权限检查
const { hasPermission: checkPerm } = usePermission();
const canChat = checkPerm('dify:chat:use');
// 侧边栏状态 // 侧边栏状态
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
// 获取主题配置,避免SSR错误 // 主题配置
const { colorBgContainer, borderRadiusLG } = useChatTheme(); const { colorBgContainer, borderRadiusLG } = CHAT_THEME;
// 对话应用管理 // 对话应用管理
const { const {
@@ -162,8 +168,11 @@ export default function Chat() {
// 应用状态 // 应用状态
const [appUnavailable, setAppUnavailable] = useState<boolean>(false); const [appUnavailable, setAppUnavailable] = useState<boolean>(false);
const [isUnknownReason, setIsUnknownReason] = useState<boolean>(false); const [isUnknownReason, setIsUnknownReason] = useState<boolean>(false);
const [conversationPermissionDenied, setConversationPermissionDenied] = useState<boolean>(false);
const [inited, setInited] = useState<boolean>(false); const [inited, setInited] = useState<boolean>(false);
const [promptConfig, setPromptConfig] = useState<any>(null); const [promptConfig, setPromptConfig] = useState<any>(null);
// 防止重复初始化
const [initializing, setInitializing] = useState<boolean>(false);
// 会话状态管理 // 会话状态管理
const [conversationIdChangeBecauseOfNew, setConversationIdChangeBecauseOfNew, getConversationIdChangeBecauseOfNew] = useGetState(false); const [conversationIdChangeBecauseOfNew, setConversationIdChangeBecauseOfNew, getConversationIdChangeBecauseOfNew] = useGetState(false);
@@ -507,7 +516,8 @@ export default function Chat() {
}; };
/** /**
* 组件初始化 - 参考webapp-conversation的逻辑 * 组件初始化 - 等待 currentChatApp 就绪后再获取会话列表
* 确保按当前应用过滤会话,避免不同应用的会话混在一起
*/ */
useEffect(() => { useEffect(() => {
if (!hasSetAppConfig) { if (!hasSetAppConfig) {
@@ -516,14 +526,28 @@ export default function Chat() {
return; return;
} }
// 必须等 currentChatApp 就绪,否则不知道该获取哪个应用的会话
if (!currentChatApp || initializing || inited) {
return;
}
setInitializing(true);
(async () => { (async () => {
try { try {
// console.log('🚀 开始初始化聊天应用...'); console.log('🚀 [Chat] 开始初始化,当前应用:', currentChatApp.app_name, currentChatApp.app_id);
// 并行获取会话列表和应用参数 // 用当前应用的 appId 获取会话列表(失败时降级为空列表,不阻塞初始化)
const [conversationData, appParams] = await Promise.all([ const [conversationData, appParams] = await Promise.all([
fetchConversations(), fetchConversations(currentChatApp.app_id).catch(err => {
fetchAppParams() console.warn('⚠️ [Chat] 获取会话列表失败(权限不足或网络问题),降级为空列表:', err.message);
setConversationPermissionDenied(true);
return { data: [] };
}),
fetchAppParams().catch(err => {
console.warn('⚠️ [Chat] 获取应用参数失败,使用默认值:', err.message);
return { data: { user_input_form: [], opening_statement: '' } };
}),
]); ]);
console.log('📋 [Chat] 获取到的数据:', { conversationData, appParams }); console.log('📋 [Chat] 获取到的数据:', { conversationData, appParams });
@@ -532,13 +556,8 @@ export default function Chat() {
const conversations = (conversationData as any).data || []; const conversations = (conversationData as any).data || [];
console.log('📋 [Chat] 会话列表:', conversations); console.log('📋 [Chat] 会话列表:', conversations);
if ((conversationData as any).error) {
console.error('❌ [Chat] 获取会话列表失败:', (conversationData as any).error);
throw new Error((conversationData as any).error);
}
// 处理当前会话ID // 处理当前会话ID
const _conversationId = getConversationIdFromStorage(CHAT_CONFIG.APP_ID); const _conversationId = getConversationIdFromStorage(currentChatApp.app_id);
const isNotNewConversation = conversations.some((item: ConversationItem) => item.id === _conversationId); const isNotNewConversation = conversations.some((item: ConversationItem) => item.id === _conversationId);
console.log('💾 [Chat] 初始化 - 本地存储的会话ID:', { console.log('💾 [Chat] 初始化 - 本地存储的会话ID:', {
@@ -568,15 +587,15 @@ export default function Chat() {
// 如果存在有效的会话ID,则设置为当前会话 // 如果存在有效的会话ID,则设置为当前会话
if (isNotNewConversation) { if (isNotNewConversation) {
console.log('🎯 [Chat] 初始化 - 设置当前会话ID:', _conversationId); console.log('🎯 [Chat] 初始化 - 设置当前会话ID:', _conversationId);
setCurrConversationId(_conversationId, CHAT_CONFIG.APP_ID, false); setCurrConversationId(_conversationId, currentChatApp.app_id, false);
} else { } else {
// 如果localStorage为空或会话不存在,自动创建新会话 // 如果localStorage为空或会话不存在,自动创建新会话
console.log('🆕 [Chat] 初始化 - localStorage为空或会话不存在,创建新会话'); console.log('🆕 [Chat] 初始化 - localStorage为空或会话不存在,创建新会话');
setCurrConversationId('-1', CHAT_CONFIG.APP_ID, false); setCurrConversationId('-1', currentChatApp.app_id, false);
} }
setInited(true); setInited(true);
// console.log('✅ 聊天应用初始化完成'); console.log('✅ [Chat] 聊天应用初始化完成');
} catch (e: any) { } catch (e: any) {
console.error('❌ 初始化失败:', e); console.error('❌ 初始化失败:', e);
if (e.status === 404) { if (e.status === 404) {
@@ -587,7 +606,7 @@ export default function Chat() {
} }
} }
})(); })();
}, []); }, [currentChatApp]);
// 监听会话切换 // 监听会话切换
useEffect(() => { useEffect(() => {
@@ -663,6 +682,18 @@ export default function Chat() {
const conversationName = currConversationInfo?.name || '新对话'; const conversationName = currConversationInfo?.name || '新对话';
const conversationIntroduction = currConversationInfo?.introduction || ''; const conversationIntroduction = currConversationInfo?.introduction || '';
// SSR 兼容:等客户端挂载后再渲染 Ant Design 组件
if (!mounted) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
<p className="text-gray-600">...</p>
</div>
</div>
);
}
return ( return (
<Layout style={{ height: '100%', display: 'flex', flexDirection: 'row', position: 'relative' }}> <Layout style={{ height: '100%', display: 'flex', flexDirection: 'row', position: 'relative' }}>
{/* 移动端遮罩层 - 点击可收起侧边栏 */} {/* 移动端遮罩层 - 点击可收起侧边栏 */}
@@ -704,6 +735,7 @@ export default function Chat() {
loadingChatApps={loadingChatApps} loadingChatApps={loadingChatApps}
currentChatApp={currentChatApp} currentChatApp={currentChatApp}
onChatAppChange={handleChatAppChange} onChatAppChange={handleChatAppChange}
conversationReadOnly={conversationPermissionDenied}
/> />
{/* 主内容区域 */} {/* 主内容区域 */}
@@ -738,6 +770,7 @@ export default function Chat() {
message={item} message={item}
isResponding={isResponding && item.id === chatList[chatList.length - 1]?.id} isResponding={isResponding && item.id === chatList[chatList.length - 1]?.id}
onFeedback={handleFeedback} onFeedback={handleFeedback}
onSuggestedQuestionClick={(question) => handleSendMessage(question)}
/> />
))} ))}
</div> </div>
@@ -747,8 +780,8 @@ export default function Chat() {
<div className="flex-shrink-0 bg-white"> <div className="flex-shrink-0 bg-white">
<ChatInput <ChatInput
onSendMessage={handleSendMessage} onSendMessage={handleSendMessage}
disabled={isResponding} disabled={isResponding || !canChat}
placeholder="有什么我能帮您的吗?" placeholder={canChat ? "有什么我能帮您的吗?" : "您没有发送消息的权限"}
onStop={stopResponding} onStop={stopResponding}
isResponding={isResponding} isResponding={isResponding}
/> />
+12 -6
View File
@@ -13,6 +13,7 @@ import { Button, Dropdown, Input, Layout, Menu, Modal, Tooltip, message, theme,
import { forwardRef, useImperativeHandle, useMemo, useState } from 'react'; import { forwardRef, useImperativeHandle, useMemo, useState } from 'react';
import type { ChatApp } from '~/api/dify-chat-apps/types'; import type { ChatApp } from '~/api/dify-chat-apps/types';
import type { ConversationItem } from '~/api/dify-chat'; import type { ConversationItem } from '~/api/dify-chat';
import { usePermission } from '~/hooks/usePermission';
import { deleteConversation, renameConversation } from '~/api/dify-chat'; import { deleteConversation, renameConversation } from '~/api/dify-chat';
import '../../styles/components/chat-with-llm/sidebar.css'; import '../../styles/components/chat-with-llm/sidebar.css';
@@ -32,6 +33,8 @@ interface ChatSidebarProps {
currentChatApp: ChatApp | null; currentChatApp: ChatApp | null;
onChatAppChange: (appId: string) => void; onChatAppChange: (appId: string) => void;
onConversationRenamed?: (conversationId: string, newName: string) => void; onConversationRenamed?: (conversationId: string, newName: string) => void;
/** 会话列表权限被拒绝时,隐藏重命名/删除按钮 */
conversationReadOnly?: boolean;
} }
// 暴露给父组件的方法接口 // 暴露给父组件的方法接口
@@ -55,7 +58,10 @@ const ChatSidebar = forwardRef<ChatSidebarRef, ChatSidebarProps>(({
onNewConversation, onNewConversation,
onConversationDeleted, onConversationDeleted,
onConversationRenamed, onConversationRenamed,
conversationReadOnly = false,
}, ref) => { }, ref) => {
const { hasPermission } = usePermission();
const canDeleteConversation = hasPermission('dify:conversation:delete');
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState('');
const [renameModalVisible, setRenameModalVisible] = useState(false); const [renameModalVisible, setRenameModalVisible] = useState(false);
const [deleteModalVisible, setDeleteModalVisible] = useState(false); const [deleteModalVisible, setDeleteModalVisible] = useState(false);
@@ -208,24 +214,24 @@ const ChatSidebar = forwardRef<ChatSidebarRef, ChatSidebarProps>(({
<span className="truncate flex-1" title={conv.name}> <span className="truncate flex-1" title={conv.name}>
{conv.name} {conv.name}
</span> </span>
{!collapsed && ( {!collapsed && (!conversationReadOnly || canDeleteConversation) && (
<Dropdown <Dropdown
menu={{ menu={{
items: [ items: [
{ ...(!conversationReadOnly ? [{
key: 'rename', key: 'rename',
icon: <EditOutlined />, icon: <EditOutlined />,
label: '重命名', label: '重命名',
onClick: () => handleRename(conv), onClick: () => handleRename(conv),
}, }] : []),
{ ...(canDeleteConversation ? [{
key: 'delete', key: 'delete',
icon: <DeleteOutlined />, icon: <DeleteOutlined />,
label: '删除', label: '删除',
danger: true, danger: true,
onClick: () => handleDeleteClick(conv), onClick: () => handleDeleteClick(conv),
}, }] : []),
], ].filter(Boolean),
}} }}
trigger={['click']} trigger={['click']}
placement="bottomRight" placement="bottomRight"
@@ -7,7 +7,7 @@
* @version 1.0.0 * @version 1.0.0
*/ */
import { useState, useEffect } from 'react'; import { useEffect } from 'react';
import { import {
Card, Card,
Table, Table,
@@ -24,7 +24,6 @@ import {
Flex, Flex,
Typography, Typography,
Popconfirm, Popconfirm,
Spin,
Tooltip, Tooltip,
} from 'antd'; } from 'antd';
import { import {
@@ -35,8 +34,7 @@ import {
CheckCircleOutlined, CheckCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useAreaDatasetConfig } from '~/hooks/use-area-dataset-config'; import { useAreaDatasetConfig } from '~/hooks/use-area-dataset-config';
import { fetchDatasets } from '~/api/dify-dataset/api/datasetApi'; import { usePermission } from '~/hooks/usePermission';
import type { Dataset as DifyDataset } from '~/api/dify-dataset/type';
import type { AreaDataset } from '~/api/v3/dify/area-datasets'; import type { AreaDataset } from '~/api/v3/dify/area-datasets';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -90,13 +88,11 @@ export default function AreaDatasetConfig() {
canManageDataset, canManageDataset,
} = useAreaDatasetConfig(); } = useAreaDatasetConfig();
const { userRole: rawUserRole, userArea: rawUserArea } = usePermission();
const isProvincialAdmin = rawUserRole === 'provincial_admin';
// 内部状态 // 内部状态
const [form] = Form.useForm(); const [form] = Form.useForm();
const [difyDatasets, setDifyDatasets] = useState<DifyDataset[]>([]);
const [difyDatasetsLoading, setDifyDatasetsLoading] = useState<boolean>(false);
const [difyDatasetsTotal, setDifyDatasetsTotal] = useState<number>(0);
const [difyDatasetsPage, setDifyDatasetsPage] = useState<number>(1);
const [isLoadingDifyDatasets, setIsLoadingDifyDatasets] = useState<boolean>(false);
// ==================== Effects ==================== // ==================== Effects ====================
@@ -107,7 +103,6 @@ export default function AreaDatasetConfig() {
if (record) { if (record) {
form.setFieldsValue({ form.setFieldsValue({
area: record.area, area: record.area,
dataset_id: record.dataset_id,
dataset_name: record.dataset_name, dataset_name: record.dataset_name,
dataset_description: record.dataset_description, dataset_description: record.dataset_description,
is_public: record.is_public, is_public: record.is_public,
@@ -116,51 +111,14 @@ export default function AreaDatasetConfig() {
}); });
} }
} else if (!editingId && modalVisible) { } else if (!editingId && modalVisible) {
// 新增时重置表单
form.resetFields(); form.resetFields();
loadDifyDatasets(); // 加载Dify知识库列表 // 非省级管理员自动填充地区
if (!isProvincialAdmin && rawUserArea) {
form.setFieldValue('area', rawUserArea);
}
} }
}, [editingId, modalVisible, datasets, form]); }, [editingId, modalVisible, datasets, form]);
// ==================== Dify Datasets Loading ====================
/**
* 从Dify API加载知识库列表
*/
const loadDifyDatasets = async (pageNum: number = 1) => {
if (isLoadingDifyDatasets) return;
setIsLoadingDifyDatasets(true);
try {
const response = await fetchDatasets(pageNum, 20);
setDifyDatasets(response.data);
setDifyDatasetsTotal(response.total);
setDifyDatasetsPage(pageNum);
setDifyDatasetsLoading(false);
} catch (error: any) {
console.error('加载Dify知识库列表失败:', error);
message.error('加载Dify知识库列表失败');
setDifyDatasetsLoading(false);
} finally {
setIsLoadingDifyDatasets(false);
}
};
/**
* Dify数据集选择器滚动加载
*/
const handleDatasetSelectScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { target } = e;
const { scrollTop, scrollHeight, clientHeight } = target as HTMLDivElement;
// 滚动到底部且还有更多数据时加载下一页
if (scrollHeight - scrollTop === clientHeight &&
difyDatasets.length < difyDatasetsTotal &&
!difyDatasetsLoading) {
loadDifyDatasets(difyDatasetsPage + 1);
}
};
// ==================== Event Handlers ==================== // ==================== Event Handlers ====================
/** /**
@@ -168,13 +126,12 @@ export default function AreaDatasetConfig() {
*/ */
const handleCreateClick = () => { const handleCreateClick = () => {
if (!canManageDataset) { if (!canManageDataset) {
message.error('您没有创建知识库绑定的权限'); message.error('您没有创建知识库的权限');
return; return;
} }
setEditingId(null); setEditingId(null);
setModalVisible(true); setModalVisible(true);
form.resetFields(); form.resetFields();
loadDifyDatasets();
}; };
/** /**
@@ -208,6 +165,24 @@ export default function AreaDatasetConfig() {
* 处理表单提交 * 处理表单提交
*/ */
const handleFormSubmit = async (values: any) => { const handleFormSubmit = async (values: any) => {
// 编辑时检查 is_default 是否从 false 变为 true
if (editingId && values.is_default) {
const record = datasets.find((item) => item.id === editingId);
if (record && !record.is_default) {
Modal.confirm({
title: '切换默认知识库',
content: '确认将此知识库设为默认?该地区的对话助手将自动绑定此知识库进行问答。',
okText: '确认',
cancelText: '取消',
onOk: () => doSubmit(values),
});
return;
}
}
await doSubmit(values);
};
const doSubmit = async (values: any) => {
let success = false; let success = false;
if (editingId) { if (editingId) {
@@ -269,6 +244,7 @@ export default function AreaDatasetConfig() {
key: 'dataset_name', key: 'dataset_name',
width: 200, width: 200,
ellipsis: true, ellipsis: true,
align: 'center',
render: (text: string) => ( render: (text: string) => (
<Tooltip title={text}> <Tooltip title={text}>
<Text style={{ color: colors.text }} strong> <Text style={{ color: colors.text }} strong>
@@ -277,25 +253,26 @@ export default function AreaDatasetConfig() {
</Tooltip> </Tooltip>
), ),
}, },
{ // {
title: '知识库ID', // title: '知识库ID',
dataIndex: 'dataset_id', // dataIndex: 'dataset_id',
key: 'dataset_id', // key: 'dataset_id',
width: 200, // width: 200,
ellipsis: true, // ellipsis: true,
render: (text: string) => ( // render: (text: string) => (
<Tooltip title={text}> // <Tooltip title={text}>
<Text type="secondary" style={{ fontSize: '12px' }}> // <Text type="secondary" style={{ fontSize: '12px' }}>
{text.substring(0, 8)}...{text.substring(text.length - 4)} // {text.substring(0, 8)}...{text.substring(text.length - 4)}
</Text> // </Text>
</Tooltip> // </Tooltip>
), // ),
}, // },
{ {
title: '描述', title: '描述',
dataIndex: 'dataset_description', dataIndex: 'dataset_description',
key: 'dataset_description', key: 'dataset_description',
ellipsis: true, ellipsis: true,
align: 'center',
render: (text: string) => render: (text: string) =>
text ? ( text ? (
<Tooltip title={text}> <Tooltip title={text}>
@@ -319,7 +296,7 @@ export default function AreaDatasetConfig() {
</Tag> </Tag>
)} )}
{record.is_default && ( {record.is_default && (
<Tag color={colors.primary} style={{ color: '#fff' }}> <Tag color={colors.primary} style={{ color: '#00684a' }}>
</Tag> </Tag>
)} )}
@@ -330,7 +307,7 @@ export default function AreaDatasetConfig() {
title: '排序', title: '排序',
dataIndex: 'sort_order', dataIndex: 'sort_order',
key: 'sort_order', key: 'sort_order',
width: 70, width: 170,
align: 'center' as const, align: 'center' as const,
sorter: (a: AreaDataset, b: AreaDataset) => a.sort_order - b.sort_order, sorter: (a: AreaDataset, b: AreaDataset) => a.sort_order - b.sort_order,
}, },
@@ -364,29 +341,37 @@ export default function AreaDatasetConfig() {
key: 'actions', key: 'actions',
width: 120, width: 120,
fixed: 'right' as const, fixed: 'right' as const,
render: (_: any, record: AreaDataset) => ( render: (_: any, record: AreaDataset) => {
<Space size="small"> // 市级管理员只能编辑自己地区的知识库
<Button const canEdit = isProvincialAdmin || record.area === rawUserArea;
type="link" return (
size="small" <Space size="small">
icon={<EditOutlined />} {canEdit && (
onClick={() => handleEditClick(record)} <Button
> type="link"
size="small"
</Button> icon={<EditOutlined />}
<Popconfirm onClick={() => handleEditClick(record)}
title="确定删除?" >
description="删除后该地区的用户将无法访问此知识库"
onConfirm={() => handleDeleteClick(record.id)} </Button>
okText="确定" )}
cancelText="取消" {isProvincialAdmin && (
> <Popconfirm
<Button type="link" danger size="small" icon={<DeleteOutlined />}> title="确定删除?"
description="删除后该地区的用户将无法访问此知识库"
</Button> onConfirm={() => handleDeleteClick(record.id)}
</Popconfirm> okText="确定"
</Space> cancelText="取消"
), >
<Button type="link" danger size="small" icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
)}
</Space>
);
},
}, },
] ]
: []), : []),
@@ -432,7 +417,7 @@ export default function AreaDatasetConfig() {
icon={<PlusOutlined />} icon={<PlusOutlined />}
onClick={handleCreateClick} onClick={handleCreateClick}
> >
</Button> </Button>
)} )}
</Flex> </Flex>
@@ -503,7 +488,7 @@ export default function AreaDatasetConfig() {
{/* 新增/编辑对话框 */} {/* 新增/编辑对话框 */}
<Modal <Modal
title={editingId ? '编辑知识库绑定' : '新增知识库绑定'} title={editingId ? '编辑知识库' : '新增知识库'}
open={modalVisible} open={modalVisible}
onOk={() => form.submit()} onOk={() => form.submit()}
onCancel={handleFormCancel} onCancel={handleFormCancel}
@@ -529,55 +514,12 @@ export default function AreaDatasetConfig() {
> >
<Select <Select
placeholder="请选择地区" placeholder="请选择地区"
disabled={!!editingId} // 编辑时禁用
options={Array.isArray(areas) ? areas.map((area) => ({
label: area,
value: area,
})) : []}
/>
</Form.Item>
{/* Dify知识库选择(仅新增时可选) */}
<Form.Item
name="dataset_id"
label="Dify知识库"
rules={[{ required: true, message: '请选择Dify知识库' }]}
>
<Select
placeholder="请选择或输入知识库ID"
disabled={!!editingId} disabled={!!editingId}
loading={difyDatasetsLoading} options={
onPopupScroll={handleDatasetSelectScroll} isProvincialAdmin
dropdownRender={(menu) => ( ? (Array.isArray(areas) ? areas.map((area) => ({ label: area, value: area })) : [])
<div> : (rawUserArea ? [{ label: rawUserArea, value: rawUserArea }] : [])
{menu} }
{difyDatasets.length < difyDatasetsTotal && (
<div style={{ textAlign: 'center', padding: '8px' }}>
<Spin size="small" />
<Text style={{ marginLeft: '8px' }} type="secondary">
...
</Text>
</div>
)}
</div>
)}
onDropdownVisibleChange={(open) => {
if (open && !editingId) {
loadDifyDatasets();
}
}}
options={difyDatasets.map((ds) => ({
label: (
<Flex vertical>
<Text strong>{ds.name}</Text>
<Text type="secondary" style={{ fontSize: '12px' }}>
ID: {ds.id}
</Text>
</Flex>
),
value: ds.id,
}))}
styles={{ popup: { root: { maxHeight: '300px' } } }}
/> />
</Form.Item> </Form.Item>
@@ -594,7 +536,7 @@ export default function AreaDatasetConfig() {
</Form.Item> </Form.Item>
{/* 知识库描述 */} {/* 知识库描述 */}
{/* <Form.Item <Form.Item
name="dataset_description" name="dataset_description"
label="知识库描述" label="知识库描述"
> >
@@ -603,36 +545,33 @@ export default function AreaDatasetConfig() {
rows={3} rows={3}
maxLength={500} maxLength={500}
/> />
</Form.Item> */} </Form.Item>
{/* 高级设置折叠面板 */} {/* 高级设置 */}
<div style={{ marginTop: '24px' }}> <div style={{ marginTop: '24px' }}>
<Text strong style={{ color: colors.text, display: 'block', marginBottom: '16px' }}> <Text strong style={{ color: colors.text, display: 'block', marginBottom: '16px' }}>
</Text> </Text>
<Flex gap="24px"> <Flex gap="24px">
{/* 是否公开 */}
<Form.Item <Form.Item
name="is_public" name="is_public"
label="公开知识库" label="公开知识库"
valuePropName="checked" valuePropName="checked"
tooltip="公开后所有地区的用户可以访问此知识库" tooltip="公开后,其他地区的用户可以在对话中选择此知识库的问答助手"
> >
<Switch /> <Switch />
</Form.Item> </Form.Item>
{/* 是否默认 */}
<Form.Item <Form.Item
name="is_default" name="is_default"
label="默认知识库" label="默认知识库"
valuePropName="checked" valuePropName="checked"
tooltip="设置为该地区的默认知识库,用户优先使用" tooltip="设为默认后,该地区的对话助手将自动切换为使用此知识库"
> >
<Switch /> <Switch />
</Form.Item> </Form.Item>
{/* 排序顺序 */}
<Form.Item <Form.Item
name="sort_order" name="sort_order"
label="排序顺序" label="排序顺序"
@@ -2,6 +2,7 @@ import { CheckCircleFilled, QuestionCircleOutlined, SaveOutlined } from '@ant-de
import { Button, Card, Checkbox, Descriptions, Divider, InputNumber, Select, Slider, Spin, Tag, Tooltip } from 'antd'; import { Button, Card, Checkbox, Descriptions, Divider, InputNumber, Select, Slider, Spin, Tag, Tooltip } from 'antd';
import { useDatasetSettings, type SearchMethod } from '~/hooks/dify-dataset-manager/dataset-settings'; import { useDatasetSettings, type SearchMethod } from '~/hooks/dify-dataset-manager/dataset-settings';
import type { DatasetSettingsProps } from '~/types/dify-dataset-manager/dataset-settings'; import type { DatasetSettingsProps } from '~/types/dify-dataset-manager/dataset-settings';
import { usePermission } from '~/hooks/usePermission';
// 检索方式选项 // 检索方式选项
const SEARCH_METHOD_OPTIONS: { label: string; value: SearchMethod; description: string }[] = [ const SEARCH_METHOD_OPTIONS: { label: string; value: SearchMethod; description: string }[] = [
@@ -19,6 +20,7 @@ const SEARCH_METHOD_OPTIONS: { label: string; value: SearchMethod; description:
export default function DatasetSettings({ export default function DatasetSettings({
dataset, dataset,
onDatasetUpdated, onDatasetUpdated,
canEditDataset = true,
}: DatasetSettingsProps) { }: DatasetSettingsProps) {
const { const {
saving, saving,
@@ -29,6 +31,9 @@ export default function DatasetSettings({
updateRetrievalSettings, updateRetrievalSettings,
} = useDatasetSettings(dataset, onDatasetUpdated); } = useDatasetSettings(dataset, onDatasetUpdated);
const { hasPermission } = usePermission();
const canWrite = hasPermission('dify:settings:write') && canEditDataset;
// 是否需要显示 Reranking 提示(语义检索和混合检索需要,且强制开启) // 是否需要显示 Reranking 提示(语义检索和混合检索需要,且强制开启)
const showRerankingInfo = retrievalSettings.searchMethod === 'semantic_search' || retrievalSettings.searchMethod === 'hybrid_search'; const showRerankingInfo = retrievalSettings.searchMethod === 'semantic_search' || retrievalSettings.searchMethod === 'hybrid_search';
// 权重设置:由于 Reranking 强制开启,混合检索时由 Reranking 模型决定排序,不需要手动设置权重 // 权重设置:由于 Reranking 强制开启,混合检索时由 Reranking 模型决定排序,不需要手动设置权重
@@ -245,7 +250,7 @@ export default function DatasetSettings({
{/* 操作按钮 */} {/* 操作按钮 */}
<div className="form-actions" style={{ marginTop: 24, display: 'flex', justifyContent: 'flex-end', gap: 8 }}> <div className="form-actions" style={{ marginTop: 24, display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={handleReset} disabled={!hasChanges}> <Button onClick={handleReset} disabled={!hasChanges || !canWrite}>
</Button> </Button>
<Button <Button
@@ -253,7 +258,7 @@ export default function DatasetSettings({
icon={<SaveOutlined />} icon={<SaveOutlined />}
onClick={handleSave} onClick={handleSave}
loading={saving} loading={saving}
disabled={!hasChanges} disabled={!hasChanges || !canWrite}
> >
</Button> </Button>
@@ -16,6 +16,7 @@ import {
EyeOutlined, EyeOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useDocumentDetail } from '~/hooks/dify-dataset-manager/document-detail'; import { useDocumentDetail } from '~/hooks/dify-dataset-manager/document-detail';
import { usePermission } from '~/hooks/usePermission';
import type { DocumentDetailProps } from '~/types/dify-dataset-manager/document-detail'; import type { DocumentDetailProps } from '~/types/dify-dataset-manager/document-detail';
import { INDEXING_STATUS_CONFIG } from '~/types/dify-dataset-manager/document-detail'; import { INDEXING_STATUS_CONFIG } from '~/types/dify-dataset-manager/document-detail';
@@ -26,6 +27,7 @@ import { INDEXING_STATUS_CONFIG } from '~/types/dify-dataset-manager/document-de
export default function DocumentDetail({ export default function DocumentDetail({
datasetId, datasetId,
document, document,
canEditDataset = true,
}: DocumentDetailProps) { }: DocumentDetailProps) {
const { const {
settings, settings,
@@ -41,6 +43,10 @@ export default function DocumentDetail({
handleSaveAndProcess, handleSaveAndProcess,
} = useDocumentDetail(datasetId, document); } = useDocumentDetail(datasetId, document);
const { hasPermission } = usePermission();
const canManageDoc = hasPermission('dify:document:manage') && canEditDataset;
const readOnly = !canManageDoc || isProcessing;
if (!document) { if (!document) {
return ( return (
<div className="document-detail-empty"> <div className="document-detail-empty">
@@ -83,7 +89,7 @@ export default function DocumentDetail({
value={settings.separator} value={settings.separator}
onChange={(e) => updateSettings('separator', e.target.value)} onChange={(e) => updateSettings('separator', e.target.value)}
placeholder="\n\n" placeholder="\n\n"
disabled={isProcessing} disabled={readOnly}
className="setting-input" className="setting-input"
/> />
</div> </div>
@@ -102,7 +108,7 @@ export default function DocumentDetail({
onChange={(value) => updateSettings('maxTokens', value || 500)} onChange={(value) => updateSettings('maxTokens', value || 500)}
min={100} min={100}
max={4000} max={4000}
disabled={isProcessing} disabled={readOnly}
className="setting-input-number" className="setting-input-number"
/> />
<span className="input-suffix">characters</span> <span className="input-suffix">characters</span>
@@ -123,7 +129,7 @@ export default function DocumentDetail({
onChange={(value) => updateSettings('chunkOverlap', value || 50)} onChange={(value) => updateSettings('chunkOverlap', value || 50)}
min={0} min={0}
max={500} max={500}
disabled={isProcessing} disabled={readOnly}
className="setting-input-number" className="setting-input-number"
/> />
<span className="input-suffix">characters</span> <span className="input-suffix">characters</span>
@@ -141,7 +147,7 @@ export default function DocumentDetail({
<Checkbox <Checkbox
checked={settings.removeExtraSpaces} checked={settings.removeExtraSpaces}
onChange={(e) => updateSettings('removeExtraSpaces', e.target.checked)} onChange={(e) => updateSettings('removeExtraSpaces', e.target.checked)}
disabled={isProcessing} disabled={readOnly}
> >
</Checkbox> </Checkbox>
@@ -149,7 +155,7 @@ export default function DocumentDetail({
<Checkbox <Checkbox
checked={settings.removeUrlsEmails} checked={settings.removeUrlsEmails}
onChange={(e) => updateSettings('removeUrlsEmails', e.target.checked)} onChange={(e) => updateSettings('removeUrlsEmails', e.target.checked)}
disabled={isProcessing} disabled={readOnly}
> >
URL URL
</Checkbox> </Checkbox>
@@ -163,16 +169,16 @@ export default function DocumentDetail({
<h3 className="section-title"></h3> <h3 className="section-title"></h3>
<div className="index-options"> <div className="index-options">
<div <div
className={`index-option ${settings.indexingTechnique === 'high_quality' ? 'active' : ''} ${isProcessing ? 'disabled' : ''}`} className={`index-option ${settings.indexingTechnique === 'high_quality' ? 'active' : ''} ${readOnly ? 'disabled' : ''}`}
onClick={() => !isProcessing && updateSettings('indexingTechnique', 'high_quality')} onClick={() => !readOnly && updateSettings('indexingTechnique', 'high_quality')}
> >
<span className="option-radio"></span> <span className="option-radio"></span>
<span className="option-label"></span> <span className="option-label"></span>
<span className="option-badge recommended"></span> <span className="option-badge recommended"></span>
</div> </div>
<div <div
className={`index-option ${settings.indexingTechnique === 'economy' ? 'active' : ''} ${isProcessing ? 'disabled' : ''}`} className={`index-option ${settings.indexingTechnique === 'economy' ? 'active' : ''} ${readOnly ? 'disabled' : ''}`}
onClick={() => !isProcessing && updateSettings('indexingTechnique', 'economy')} onClick={() => !readOnly && updateSettings('indexingTechnique', 'economy')}
> >
<span className="option-radio"></span> <span className="option-radio"></span>
<span className="option-label"></span> <span className="option-label"></span>
@@ -190,29 +196,33 @@ export default function DocumentDetail({
> >
</Button> </Button>
<Button {canManageDoc && (
icon={<ReloadOutlined />} <Button
onClick={handleReset} icon={<ReloadOutlined />}
disabled={isProcessing} onClick={handleReset}
> disabled={isProcessing}
>
</Button>
</Button>
)}
</div> </div>
<Divider /> <Divider />
{/* 保存并处理按钮 */} {/* 保存并处理按钮 */}
<div className="save-actions"> {canManageDoc && (
<Button <div className="save-actions">
type="primary" <Button
onClick={handleSaveAndProcess} type="primary"
loading={saving} onClick={handleSaveAndProcess}
disabled={isProcessing} loading={saving}
block disabled={isProcessing}
> block
{isProcessing ? '处理中...' : '保存并处理'} >
</Button> {isProcessing ? '处理中...' : '保存并处理'}
</div> </Button>
</div>
)}
</div> </div>
{/* 右侧预览区域 */} {/* 右侧预览区域 */}
@@ -22,6 +22,7 @@ import type { ColumnsType } from 'antd/es/table';
import type { Document, IndexingStatus } from '~/api/dify-dataset/type/documentTypes'; import type { Document, IndexingStatus } from '~/api/dify-dataset/type/documentTypes';
import { useDocumentList } from '~/hooks/dify-dataset-manager/document-list'; import { useDocumentList } from '~/hooks/dify-dataset-manager/document-list';
import type { DocumentListProps } from '~/types/dify-dataset-manager/document-list'; import type { DocumentListProps } from '~/types/dify-dataset-manager/document-list';
import { usePermission } from '~/hooks/usePermission';
import '../../styles/components/dify-dataset-manager/index.css'; import '../../styles/components/dify-dataset-manager/index.css';
import DocumentUpload from './document-upload'; import DocumentUpload from './document-upload';
@@ -40,6 +41,7 @@ export default function DocumentList({
onDocumentStatusChanged, onDocumentStatusChanged,
onRefresh, onRefresh,
onViewDocument, onViewDocument,
canEditDataset = true,
}: DocumentListProps) { }: DocumentListProps) {
const { const {
searchValue, searchValue,
@@ -57,6 +59,9 @@ export default function DocumentList({
filterDocuments, filterDocuments,
} = useDocumentList(datasetId, onDocumentDeleted, onDocumentStatusChanged, onRefresh); } = useDocumentList(datasetId, onDocumentDeleted, onDocumentStatusChanged, onRefresh);
const { hasPermission } = usePermission();
const canWrite = hasPermission('dify:document:manage') && canEditDataset;
// 过滤文档 // 过滤文档
const filteredDocuments = filterDocuments(documents); const filteredDocuments = filterDocuments(documents);
@@ -111,6 +116,7 @@ export default function DocumentList({
<Switch <Switch
size="small" size="small"
checked={enabled} checked={enabled}
disabled={!canWrite}
onChange={(checked) => handleToggleStatus(record.id, checked)} onChange={(checked) => handleToggleStatus(record.id, checked)}
/> />
), ),
@@ -136,24 +142,26 @@ export default function DocumentList({
onClick={() => onViewDocument?.(record)} onClick={() => onViewDocument?.(record)}
/> />
</Tooltip> </Tooltip>
<Popconfirm {canWrite && (
title="确定要删除这个文档吗?" <Popconfirm
description="删除后无法恢复" title="确定要删除这个文档吗?"
onConfirm={() => handleDelete(record.id)} description="删除后无法恢复"
okText="确定" onConfirm={() => handleDelete(record.id)}
cancelText="取消" okText="确定"
okButtonProps={{ danger: true }} cancelText="取消"
> okButtonProps={{ danger: true }}
<Tooltip title="删除"> >
<Button <Tooltip title="删除">
type="text" <Button
size="small" type="text"
danger size="small"
icon={<DeleteOutlined />} danger
loading={deletingId === record.id} icon={<DeleteOutlined />}
/> loading={deletingId === record.id}
</Tooltip> />
</Popconfirm> </Tooltip>
</Popconfirm>
)}
</Space> </Space>
), ),
}, },
@@ -183,14 +191,16 @@ export default function DocumentList({
loading={loading} loading={loading}
/> />
</Tooltip> </Tooltip>
<Button {canWrite && (
type="primary" <Button
icon={<CloudUploadOutlined />} type="primary"
onClick={handleUploadClick} icon={<CloudUploadOutlined />}
disabled={!datasetId} onClick={handleUploadClick}
> disabled={!datasetId}
>
</Button>
</Button>
)}
</div> </div>
</div> </div>
@@ -6,6 +6,7 @@ import RetrieveTest from './retrieve-test';
import DatasetSettings from './dataset-settings'; import DatasetSettings from './dataset-settings';
import AreaDatasetConfig from './area-dataset-config'; import AreaDatasetConfig from './area-dataset-config';
import { useDatasetManager } from '~/hooks/dify-dataset-manager'; import { useDatasetManager } from '~/hooks/dify-dataset-manager';
import { usePermission } from '~/hooks/usePermission';
import '../../styles/components/dify-dataset-manager/index.css'; import '../../styles/components/dify-dataset-manager/index.css';
/** /**
@@ -43,6 +44,10 @@ export default function DatasetManager() {
handleDatasetChange, handleDatasetChange,
} = useDatasetManager(); } = useDatasetManager();
// 判断当前用户是否能编辑当前知识库(省级管理员可编辑全部,市级管理员只能编辑本地区)
const { userRole, userArea } = usePermission();
const canEditDataset = userRole === 'provincial_admin' || ((dataset as any)?.area === userArea);
// 加载中状态 // 加载中状态
if (!inited || loadingDataset) { if (!inited || loadingDataset) {
return ( return (
@@ -80,6 +85,7 @@ export default function DatasetManager() {
<DocumentDetail <DocumentDetail
datasetId={dataset?.id || ''} datasetId={dataset?.id || ''}
document={selectedDocument} document={selectedDocument}
canEditDataset={canEditDataset}
/> />
); );
} }
@@ -98,6 +104,7 @@ export default function DatasetManager() {
onDocumentStatusChanged={handleDocumentStatusChanged} onDocumentStatusChanged={handleDocumentStatusChanged}
onRefresh={handleRefresh} onRefresh={handleRefresh}
onViewDocument={handleViewDocument} onViewDocument={handleViewDocument}
canEditDataset={canEditDataset}
/> />
); );
} }
@@ -118,6 +125,7 @@ export default function DatasetManager() {
<DatasetSettings <DatasetSettings
dataset={dataset} dataset={dataset}
onDatasetUpdated={handleDatasetUpdated} onDatasetUpdated={handleDatasetUpdated}
canEditDataset={canEditDataset}
/> />
); );
} }
@@ -9,6 +9,7 @@ import {
SwapOutlined, SwapOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { DatasetLayoutProps, MenuTab, MenuItem } from '~/types/dify-dataset-manager/layout'; import type { DatasetLayoutProps, MenuTab, MenuItem } from '~/types/dify-dataset-manager/layout';
import { usePermission } from '~/hooks/usePermission';
/** /**
* 知识库布局组件 * 知识库布局组件
@@ -25,11 +26,13 @@ export default function DatasetLayout({
loadingAvailableDatasets = false, loadingAvailableDatasets = false,
onDatasetChange, onDatasetChange,
}: DatasetLayoutProps) { }: DatasetLayoutProps) {
const { hasPermission } = usePermission();
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
{ key: 'documents', icon: <FileTextOutlined />, label: '文档' }, ...(hasPermission('dify:dataset:read') ? [{ key: 'documents' as MenuTab, icon: <FileTextOutlined />, label: '文档' }] : []),
{ key: 'retrieve', icon: <SearchOutlined />, label: '召回测试' }, ...(hasPermission('dify:retrieve:test') ? [{ key: 'retrieve' as MenuTab, icon: <SearchOutlined />, label: '召回测试' }] : []),
{ key: 'area-config', icon: <AppstoreOutlined />, label: '配置管理' }, ...(hasPermission('dify:config:manage') ? [{ key: 'area-config' as MenuTab, icon: <AppstoreOutlined />, label: '配置管理' }] : []),
{ key: 'settings', icon: <SettingOutlined />, label: '设置' }, ...(hasPermission('dify:settings:write') ? [{ key: 'settings' as MenuTab, icon: <SettingOutlined />, label: '设置' }] : []),
]; ];
// 是否显示知识库选择器(有多个知识库时显示) // 是否显示知识库选择器(有多个知识库时显示)
+15 -18
View File
@@ -136,36 +136,33 @@ export function useChatApps() {
setLoadingDefault(true); setLoadingDefault(true);
setError(null); setError(null);
let resolved = false; // 用局部变量跟踪,避免 React state 异步读取的问题
try { try {
try { try {
console.log('[useChatApps] ==================== 开始初始化对话应用 ====================');
// 尝试加载可用应用列表
console.log('[useChatApps] 步骤1: 调用loadChatApps()加载我的应用列表...');
const apps = await loadChatApps(); const apps = await loadChatApps();
console.log('[useChatApps] 步骤1完成: 加载到', apps.length, '个应用');
if (apps.length > 0) { if (apps.length > 0) {
// 查找默认应用
const defaultApp = apps.find((item) => item.is_default) || apps[0]; const defaultApp = apps.find((item) => item.is_default) || apps[0];
// console.log('[useChatApps] 默认对话应用:', apps);
setCurrentChatApp(defaultApp); setCurrentChatApp(defaultApp);
console.log('[useChatApps] ==================== 初始化完成(路径1 ===================='); resolved = true;
} else { } else {
// 如果没有配置应用,尝试获取默认应用 const app = await loadDefaultChatApp();
console.log('[useChatApps] 应用列表为空,调用loadDefaultChatApp()...'); if (app) resolved = true;
await loadDefaultChatApp();
console.log('[useChatApps] ==================== 初始化完成(路径2 ====================');
} }
} catch (err) { } catch (err) {
// 加载应用列表失败,尝试获取默认应用 const app = await loadDefaultChatApp();
console.warn('[useChatApps] 加载应用列表失败,尝试获取默认应用:', err); if (app) resolved = true;
await loadDefaultChatApp();
console.log('[useChatApps] ==================== 初始化完成(路径3 ====================');
} }
} catch (err: any) { } catch (err: any) {
console.error('[useChatApps] 初始化失败:', err); console.warn('[useChatApps] 初始化异常:', err.message);
setError(err.message || '加载对话应用失败');
} finally { } finally {
if (!resolved) {
// 权限不足等情况,构造占位应用让页面能渲染(输入框会被禁用)
const fallbackApp = { app_id: '_fallback', app_name: '法务问答', description: '', is_default: true } as any;
setChatApps([fallbackApp]);
setCurrentChatApp(fallbackApp);
setError(null);
}
setLoadingChatApps(false); setLoadingChatApps(false);
setLoadingDefault(false); setLoadingDefault(false);
setInited(true); setInited(true);
@@ -94,11 +94,24 @@ export function useDocumentDetail(datasetId: string, document: Document | null)
pollIndexingStatus(batch); pollIndexingStatus(batch);
}, [stopPolling, pollIndexingStatus]); }, [stopPolling, pollIndexingStatus]);
// 当文档变化时重置设置 // 当文档变化时,从文档已有的 process_rule 回显设置,无则使用默认值
useEffect(() => { useEffect(() => {
if (document) { if (document) {
// 可以从文档中读取已有的设置,这里使用默认值 const rule = (document as any).process_rule;
setSettings(DEFAULT_DOCUMENT_DETAIL_SETTINGS); if (rule?.mode === 'custom' && rule?.rules) {
const seg = rule.rules.segmentation || {};
const preRules = rule.rules.pre_processing_rules || [];
setSettings({
separator: (seg.separator || '\\n\\n').replace(/\n/g, '\\n'),
maxTokens: seg.max_tokens || DEFAULT_DOCUMENT_DETAIL_SETTINGS.maxTokens,
chunkOverlap: DEFAULT_DOCUMENT_DETAIL_SETTINGS.chunkOverlap,
removeExtraSpaces: preRules.find((r: any) => r.id === 'remove_extra_spaces')?.enabled ?? DEFAULT_DOCUMENT_DETAIL_SETTINGS.removeExtraSpaces,
removeUrlsEmails: preRules.find((r: any) => r.id === 'remove_urls_emails')?.enabled ?? DEFAULT_DOCUMENT_DETAIL_SETTINGS.removeUrlsEmails,
indexingTechnique: DEFAULT_DOCUMENT_DETAIL_SETTINGS.indexingTechnique,
});
} else {
setSettings(DEFAULT_DOCUMENT_DETAIL_SETTINGS);
}
setPreviewSegments([]); setPreviewSegments([]);
setShowPreview(false); setShowPreview(false);
setIsProcessing(false); setIsProcessing(false);
+9 -5
View File
@@ -105,8 +105,10 @@ export function useDatasetManager() {
await loadDocuments(datasetId, 1); await loadDocuments(datasetId, 1);
} catch (err: any) { } catch (err: any) {
console.error('[DatasetManager] 加载知识库详情失败:', err); console.error('[DatasetManager] 加载知识库详情失败:', err);
setError(err.message || '加载知识库失败'); const is403 = err.message?.includes('403') || err.response?.status === 403;
message.error('加载知识库失败'); const msg = is403 ? '您没有查看知识库的权限' : (err.message || '加载知识库失败');
setError(msg);
message.error(msg);
} finally { } finally {
setLoadingDataset(false); setLoadingDataset(false);
} }
@@ -151,13 +153,15 @@ export function useDatasetManager() {
setDataset(fullDataset); setDataset(fullDataset);
await loadDocuments(firstDatasetId, 1); await loadDocuments(firstDatasetId, 1);
} else { } else {
setError('未找到知识库,请先在Dify中创建知识库'); setError('未找到知识库,请先联系管理员创建知识库');
} }
} }
} catch (err: any) { } catch (err: any) {
console.error('[DatasetManager] 加载知识库失败:', err); console.error('[DatasetManager] 加载知识库失败:', err);
setError(err.message || '加载知识库失败'); const is403 = err.message?.includes('403') || err.response?.status === 403;
message.error('加载知识库失败'); const msg = is403 ? '您没有查看知识库的权限' : (err.message || '加载知识库失败');
setError(msg);
message.error(msg);
} finally { } finally {
setLoadingDataset(false); setLoadingDataset(false);
setInited(true); setInited(true);
+1 -1
View File
@@ -72,7 +72,7 @@ export function useAreaDatasetConfig(): UseAreaDatasetConfigReturn {
// 根据权限判断是否可以管理知识库配置 // 根据权限判断是否可以管理知识库配置
// 权限键:dify:bind:update(知识库绑定更新权限) // 权限键:dify:bind:update(知识库绑定更新权限)
// 降级方案:如果 permissionMap 中没有配置权限,usePermission 会自动降级为角色判断 // 降级方案:如果 permissionMap 中没有配置权限,usePermission 会自动降级为角色判断
const canManageDataset = hasPermission('dify:bind:update'); const canManageDataset = hasPermission('dify:config:manage');
const canViewDataset = true; // 所有登录用户都可以查看 const canViewDataset = true; // 所有登录用户都可以查看
// 🔍 调试日志(修复后可删除) // 🔍 调试日志(修复后可删除)
+16 -2
View File
@@ -426,11 +426,21 @@ export default function useChatMessage({
resourceCount: messageEnd.metadata?.retriever_resources?.length || 0 resourceCount: messageEnd.metadata?.retriever_resources?.length || 0
}); });
let needUpdate = false;
// 如果有检索资源,更新响应项 // 如果有检索资源,更新响应项
if (messageEnd.metadata?.retriever_resources && messageEnd.metadata.retriever_resources.length > 0) { if (messageEnd.metadata?.retriever_resources && messageEnd.metadata.retriever_resources.length > 0) {
responseItem.retriever_resources = messageEnd.metadata.retriever_resources; responseItem.retriever_resources = messageEnd.metadata.retriever_resources;
needUpdate = true;
}
// 更新聊天列表 // 如果有建议问题,更新响应项
if (messageEnd.metadata?.suggested_questions && messageEnd.metadata.suggested_questions.length > 0) {
responseItem.suggestedQuestions = messageEnd.metadata.suggested_questions;
needUpdate = true;
}
if (needUpdate) {
updateCurrentQA({ updateCurrentQA({
responseItem: { ...responseItem }, responseItem: { ...responseItem },
questionId, questionId,
@@ -504,8 +514,12 @@ export default function useChatMessage({
draft[messageIndex].feedback = feedback; draft[messageIndex].feedback = feedback;
} }
})); }));
} catch (err) { } catch (err: any) {
logError(`提交反馈时出错: ${err}`); logError(`提交反馈时出错: ${err}`);
const msg = err?.message || '提交反馈失败';
const isPermission = msg.includes('403') || msg.includes('权限');
const { message: antMessage } = await import('antd');
antMessage.error(isPermission ? '您没有反馈权限' : msg);
} }
}, [logError, getChatList, setChatList]); }, [logError, getChatList, setChatList]);
+3
View File
@@ -28,6 +28,7 @@ interface RootLoaderData {
permissions?: string[]; permissions?: string[];
permissionMap?: Record<string, string[]>; // ✅ 新增:权限映射表 permissionMap?: Record<string, string[]>; // ✅ 新增:权限映射表
userRole: string; userRole: string;
userArea?: string;
userInfo?: { userInfo?: {
role_id?: number; role_id?: number;
role_key?: string; role_key?: string;
@@ -70,6 +71,7 @@ export function usePermission() {
// 从root loader获取权限映射表 // 从root loader获取权限映射表
const permissionMap = rootData?.permissionMap || {}; const permissionMap = rootData?.permissionMap || {};
const userRole = rootData?.userRole || 'common'; const userRole = rootData?.userRole || 'common';
const userArea = rootData?.userArea || '';
// 🔑 根据当前路由获取权限列表 // 🔑 根据当前路由获取权限列表
const currentPath = location.pathname; const currentPath = location.pathname;
@@ -245,6 +247,7 @@ export function usePermission() {
permissions: currentPermissions, // ✅ 返回当前路由的权限 permissions: currentPermissions, // ✅ 返回当前路由的权限
permissionMap, // ✅ 返回完整的权限映射表 permissionMap, // ✅ 返回完整的权限映射表
userRole, userRole,
userArea,
// 基础检查方法 // 基础检查方法
hasPermission, hasPermission,
+3
View File
@@ -200,6 +200,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
// 获取用户角色和 JWT(从 Cookie Session // 获取用户角色和 JWT(从 Cookie Session
let userRole: UserRole = 'common'; // 默认为普通用户 let userRole: UserRole = 'common'; // 默认为普通用户
let userArea: string = '';
let frontendJWT: string | null = null; let frontendJWT: string | null = null;
let allowedPaths: string[] = []; // 用户允许访问的路由列表 let allowedPaths: string[] = []; // 用户允许访问的路由列表
let permissionMap: Record<string, string[]> = {}; // ✅ 权限映射表 let permissionMap: Record<string, string[]> = {}; // ✅ 权限映射表
@@ -209,6 +210,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
const { getUserSession } = await import("~/api/login/auth.server"); const { getUserSession } = await import("~/api/login/auth.server");
const session = await getUserSession(request); const session = await getUserSession(request);
userRole = session.userRole; userRole = session.userRole;
userArea = session.userInfo?.area || '';
frontendJWT = session.frontendJWT || null; frontendJWT = session.frontendJWT || null;
// 🔑 检查用户角色和JWT是否为空 // 🔑 检查用户角色和JWT是否为空
@@ -344,6 +346,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
// 向组件传递路径信息 // 向组件传递路径信息
return Response.json({ return Response.json({
userRole, // ✅ 返回真实的用户角色 userRole, // ✅ 返回真实的用户角色
userArea, // ✅ 返回用户所属地区
pathname, pathname,
frontendJWT, frontendJWT,
isPublicPath, // 传递给客户端,用于判断是否需要认证 isPublicPath, // 传递给客户端,用于判断是否需要认证
+4 -2
View File
@@ -47,7 +47,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
} catch (error: any) { } catch (error: any) {
console.error('❌ [API] Chat Messages GET - Error:', error.message); console.error('❌ [API] Chat Messages GET - Error:', error.message);
const status = error.message?.includes('JWT认证失败') ? 401 : 500; const sm = error.message?.match(/(\d{3})/); const os = sm ? parseInt(sm[1]) : 0;
const status = error.message?.includes('JWT认证失败') ? 401 : os >= 400 && os < 500 ? os : 500;
return new Response( return new Response(
JSON.stringify({ error: error.message || 'Failed to get messages' }), JSON.stringify({ error: error.message || 'Failed to get messages' }),
{ {
@@ -148,7 +149,8 @@ export async function action({ request }: ActionFunctionArgs) {
}); });
// 检查是否是JWT认证失败 // 检查是否是JWT认证失败
const status = error.message?.includes('JWT认证失败') ? 401 : 500; const sm = error.message?.match(/(\d{3})/); const os = sm ? parseInt(sm[1]) : 0;
const status = error.message?.includes('JWT认证失败') ? 401 : os >= 400 && os < 500 ? os : 500;
return new Response( return new Response(
JSON.stringify({ error: error.message || 'Failed to send message' }), JSON.stringify({ error: error.message || 'Failed to send message' }),
+2 -1
View File
@@ -50,7 +50,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
console.error('❌ [API] Rename Conversation API - Error:', error); console.error('❌ [API] Rename Conversation API - Error:', error);
// 检查是否是JWT认证失败 // 检查是否是JWT认证失败
const status = error.message?.includes('JWT认证失败') ? 401 : 500; const sm = error.message?.match(/(\d{3})/); const os = sm ? parseInt(sm[1]) : 0;
const status = error.message?.includes('JWT认证失败') ? 401 : os >= 400 && os < 500 ? os : 500;
return json( return json(
{ {
+2 -1
View File
@@ -47,7 +47,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
console.error('❌ [API] Delete Conversation API - Error:', error); console.error('❌ [API] Delete Conversation API - Error:', error);
// 检查是否是JWT认证失败 // 检查是否是JWT认证失败
const status = error.message?.includes('JWT认证失败') ? 401 : 500; const sm = error.message?.match(/(\d{3})/); const os = sm ? parseInt(sm[1]) : 0;
const status = error.message?.includes('JWT认证失败') ? 401 : os >= 400 && os < 500 ? os : 500;
return json( return json(
{ {
+6 -2
View File
@@ -39,8 +39,12 @@ export async function loader({ request }: LoaderFunctionArgs) {
} catch (error: any) { } catch (error: any) {
console.error('❌ [API] Conversations API - Error:', error); console.error('❌ [API] Conversations API - Error:', error);
// 检查是否是JWT认证失败 // 从错误中提取原始 HTTP 状态码
const status = error.message?.includes('JWT认证失败') ? 401 : 500; const statusMatch = error.message?.match(/(\d{3})/);
const originalStatus = statusMatch ? parseInt(statusMatch[1]) : 0;
const status = error.message?.includes('JWT认证失败') ? 401
: originalStatus >= 400 && originalStatus < 500 ? originalStatus
: 500;
return json( return json(
{ {
@@ -55,7 +55,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
} catch (error: any) { } catch (error: any) {
console.error('[API] Message Feedback - Error:', error.message); console.error('[API] Message Feedback - Error:', error.message);
const status = error.message?.includes('JWT认证失败') ? 401 : 500; const sm = error.message?.match(/(\d{3})/); const os = sm ? parseInt(sm[1]) : 0;
const status = error.message?.includes('JWT认证失败') ? 401 : os >= 400 && os < 500 ? os : 500;
return new Response( return new Response(
JSON.stringify({ error: error.message || 'Failed to submit feedback' }), JSON.stringify({ error: error.message || 'Failed to submit feedback' }),
{ {
+2 -1
View File
@@ -34,7 +34,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
console.error('❌ [API] Parameters API - Error:', error); console.error('❌ [API] Parameters API - Error:', error);
// 检查是否是JWT认证失败 // 检查是否是JWT认证失败
const status = error.message?.includes('JWT认证失败') ? 401 : 500; const sm = error.message?.match(/(\d{3})/); const os = sm ? parseInt(sm[1]) : 0;
const status = error.message?.includes('JWT认证失败') ? 401 : os >= 400 && os < 500 ? os : 500;
return json( return json(
{ error: error.message || 'Failed to fetch parameters' }, { error: error.message || 'Failed to fetch parameters' },
@@ -855,11 +855,11 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
/* 关键修复:添加高度约束和内部滚动 */ flex: 1;
height: 100%;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding-bottom: 24px;
} }
.segment-item { .segment-item {
@@ -6,6 +6,7 @@ import type { Dataset } from '~/api/dify-dataset/type/datasetTypes';
export interface DatasetSettingsProps { export interface DatasetSettingsProps {
dataset: Dataset | null; dataset: Dataset | null;
onDatasetUpdated: (dataset: Dataset) => void; onDatasetUpdated: (dataset: Dataset) => void;
canEditDataset?: boolean;
} }
/** /**
@@ -7,6 +7,7 @@ import type { Document } from '~/api/dify-dataset/type/documentTypes';
export interface DocumentDetailProps { export interface DocumentDetailProps {
datasetId: string; datasetId: string;
document: Document | null; document: Document | null;
canEditDataset?: boolean;
} }
/** /**
@@ -16,6 +16,8 @@ export interface DocumentListProps {
onDocumentStatusChanged: (documentId: string, enabled: boolean) => void; onDocumentStatusChanged: (documentId: string, enabled: boolean) => void;
onRefresh: () => void; onRefresh: () => void;
onViewDocument?: (document: Document) => void; onViewDocument?: (document: Document) => void;
/** 是否能编辑当前知识库(地区归属校验)*/
canEditDataset?: boolean;
} }
/** /**