diff --git a/.gitignore b/.gitignore index a547bf3..b260b04 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ dist-ssr *.njsproj *.sln *.sw? +.env* \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ac545a5..74fc24a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,16 @@ "dependencies": { "@popperjs/core": "^2.11.8", "@reduxjs/toolkit": "^2.6.0", + "axios": "^1.8.1", "bootstrap": "^5.3.3", + "crypto-js": "^4.2.0", + "lodash": "^4.17.21", "react": "^19.0.0", "react-dom": "^19.0.0", "react-redux": "^9.2.0", "react-router-dom": "^7.2.0", - "redux-persist": "^6.0.0" + "redux-persist": "^6.0.0", + "uuid": "^11.1.0" }, "devDependencies": { "@eslint/js": "^9.21.0", @@ -1535,6 +1539,11 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -1550,6 +1559,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.1.tgz", + "integrity": "sha512-NN+fvwH/kV01dYUQ3PTOZns4LWtWhOFCAhQ/pHb88WQ1hNe5V/dvFwc4VJcDL11LT9xSX0QtsR8sWUuyOuOq7g==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1662,7 +1681,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -1786,6 +1804,17 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1820,6 +1849,11 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -1934,6 +1968,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -1950,7 +1992,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -2035,7 +2076,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -2044,7 +2084,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -2080,7 +2119,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "dependencies": { "es-errors": "^1.3.0" }, @@ -2092,7 +2130,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", @@ -2469,6 +2506,25 @@ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -2484,6 +2540,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2502,7 +2572,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2549,7 +2618,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -2573,7 +2641,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -2643,7 +2710,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -2703,7 +2769,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -2715,7 +2780,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "dependencies": { "has-symbols": "^1.0.3" }, @@ -2730,7 +2794,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -3308,6 +3371,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3339,11 +3407,29 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "engines": { "node": ">= 0.4" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3676,6 +3762,11 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4456,6 +4547,18 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vite": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz", diff --git a/package.json b/package.json index e788603..ce9ab41 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,16 @@ "dependencies": { "@popperjs/core": "^2.11.8", "@reduxjs/toolkit": "^2.6.0", + "axios": "^1.8.1", "bootstrap": "^5.3.3", + "crypto-js": "^4.2.0", + "lodash": "^4.17.21", "react": "^19.0.0", "react-dom": "^19.0.0", "react-redux": "^9.2.0", "react-router-dom": "^7.2.0", - "redux-persist": "^6.0.0" + "redux-persist": "^6.0.0", + "uuid": "^11.1.0" }, "devDependencies": { "@eslint/js": "^9.21.0", diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..9da09c4 --- /dev/null +++ b/public/index.html @@ -0,0 +1,12 @@ + + + + + + OOIN 智能知识库 + + + +
+ + \ No newline at end of file diff --git a/public/vite.svg b/public/vite.svg index e7b8dfb..40d10df 100644 --- a/public/vite.svg +++ b/public/vite.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx index d0dde09..6263565 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,7 +1,60 @@ -import HeaderWithNav from './layouts/HeaderWithNav'; -import AppRouter from './router'; +import { useDispatch, useSelector } from 'react-redux'; +import AppRouter from './router/router'; +import { checkAuthThunk } from './store/auth/auth.thunk'; +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { login } from './store/auth/auth.slice'; +import { initWebSocket, closeWebSocket } from './services/websocket'; +import { setWebSocketConnected } from './store/notificationCenter/notificationCenter.slice'; function App() { + const navigate = useNavigate(); + const dispatch = useDispatch(); + + const { user } = useSelector((state) => state.auth); + const { isConnected } = useSelector((state) => state.notificationCenter); + + // 检查用户认证状态 + useEffect(() => { + handleCheckAuth(); + }, [dispatch]); + + // 管理WebSocket连接 + useEffect(() => { + console.log(user, isConnected); + + // 如果用户已认证但WebSocket未连接,则初始化连接 + if (user && !isConnected) { + // initWebSocket() + // .then(() => { + // dispatch(setWebSocketConnected(true)); + // console.log('WebSocket connection initialized'); + // }) + // .catch((error) => { + // console.error('Failed to initialize WebSocket connection:', error); + // }); + } + + // 组件卸载或用户登出时关闭WebSocket连接 + return () => { + if (isConnected) { + closeWebSocket(); + dispatch(setWebSocketConnected(false)); + } + }; + }, [user, isConnected, dispatch]); + + const handleCheckAuth = async () => { + console.log('app handleCheckAuth'); + try { + await dispatch(checkAuthThunk()).unwrap(); + if (!user) navigate('/login'); + } catch (error) { + console.log('error', error); + navigate('/login'); + } + }; + return ; } diff --git a/src/assets/react.svg b/src/assets/react.svg index 6c87de9..22c3eba 100644 --- a/src/assets/react.svg +++ b/src/assets/react.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/components/AccessRequestModal.jsx b/src/components/AccessRequestModal.jsx new file mode 100644 index 0000000..6a8654c --- /dev/null +++ b/src/components/AccessRequestModal.jsx @@ -0,0 +1,232 @@ +import React, { useState } from 'react'; +import SvgIcon from './SvgIcon'; + +/** + * 申请权限弹窗组件 + * @param {Object} props + * @param {boolean} props.show - 是否显示弹窗 + * @param {string} props.knowledgeBaseId - 知识库ID + * @param {string} props.knowledgeBaseTitle - 知识库标题 + * @param {Function} props.onClose - 关闭弹窗的回调函数 + * @param {Function} props.onSubmit - 提交申请的回调函数,接收 requestData 参数 + * @param {boolean} props.isSubmitting - 是否正在提交 + */ +export default function AccessRequestModal({ + show, + knowledgeBaseId, + knowledgeBaseTitle, + onClose, + onSubmit, + isSubmitting = false, +}) { + const [accessRequestData, setAccessRequestData] = useState({ + permissions: { + can_read: true, + can_edit: false, + can_delete: false, + }, + duration: '30', // 默认30天 + reason: '', + }); + const [accessRequestErrors, setAccessRequestErrors] = useState({}); + + const handleAccessRequestInputChange = (e) => { + const { name, value } = e.target; + if (name === 'duration') { + setAccessRequestData((prev) => ({ + ...prev, + [name]: value, + })); + } else if (name === 'reason') { + setAccessRequestData((prev) => ({ + ...prev, + [name]: value, + })); + } + + // Clear error when user types + if (accessRequestErrors[name]) { + setAccessRequestErrors((prev) => ({ + ...prev, + [name]: '', + })); + } + }; + + const handlePermissionChange = (permissionType) => { + setAccessRequestData((prev) => ({ + ...prev, + permissions: { + can_read: true, // 只读权限始终为true + can_edit: permissionType === '编辑权限', + can_delete: false, // 管理权限暂时不开放 + }, + })); + }; + + const validateAccessRequestForm = () => { + const errors = {}; + + if (!accessRequestData.reason.trim()) { + errors.reason = '请输入申请原因'; + } + + setAccessRequestErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleSubmitAccessRequest = () => { + // Validate form + if (!validateAccessRequestForm()) { + return; + } + + // 计算过期日期 + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + parseInt(accessRequestData.duration)); + const expiresAt = expirationDate.toISOString(); + + // 调用父组件的提交函数 + onSubmit({ + knowledge_base: knowledgeBaseId, + permissions: accessRequestData.permissions, + reason: accessRequestData.reason, + expires_at: expiresAt, + }); + + // 重置表单 + setAccessRequestData({ + permissions: { + can_read: true, + can_edit: false, + can_delete: false, + }, + duration: '30', + reason: '', + }); + setAccessRequestErrors({}); + }; + + if (!show) return null; + + return ( +
+
+
+
申请访问权限
+ +
+
+
+ + +
+
+ + +
+ +
+ + +
+
+ + + {accessRequestErrors.reason && ( +
{accessRequestErrors.reason}
+ )} +
+
+
+ + +
+
+
+ ); +} diff --git a/src/components/ApiModeSwitch.jsx b/src/components/ApiModeSwitch.jsx new file mode 100644 index 0000000..1dbc4a6 --- /dev/null +++ b/src/components/ApiModeSwitch.jsx @@ -0,0 +1,83 @@ +import React, { useState, useEffect } from 'react'; +import { switchToMockApi, switchToRealApi, checkServerStatus } from '../services/api'; + +export default function ApiModeSwitch() { + const [isMockMode, setIsMockMode] = useState(false); + const [isChecking, setIsChecking] = useState(false); + const [showNotification, setShowNotification] = useState(false); + const [notification, setNotification] = useState({ message: '', type: 'info' }); + + // 组件加载时检查服务器状态 + useEffect(() => { + const checkStatus = async () => { + setIsChecking(true); + const isServerUp = await checkServerStatus(); + setIsMockMode(!isServerUp); + setIsChecking(false); + }; + + checkStatus(); + }, []); + + // 切换API模式 + const handleToggleMode = async () => { + setIsChecking(true); + + if (isMockMode) { + // 尝试切换回真实API + const isServerUp = await switchToRealApi(); + if (isServerUp) { + setIsMockMode(false); + showNotificationMessage('已切换到真实API模式', 'success'); + } else { + showNotificationMessage('服务器连接失败,继续使用模拟数据', 'warning'); + } + } else { + // 切换到模拟API + switchToMockApi(); + setIsMockMode(true); + showNotificationMessage('已切换到模拟API模式', 'info'); + } + + setIsChecking(false); + }; + + // 显示通知消息 + const showNotificationMessage = (message, type) => { + setNotification({ message, type }); + setShowNotification(true); + + // 3秒后自动隐藏通知 + setTimeout(() => { + setShowNotification(false); + }, 3000); + }; + + return ( +
+
+
+ + +
+ {isMockMode && 使用本地模拟数据} + {!isMockMode && 已连接到后端服务器} +
+ + {showNotification && ( +
+ {notification.message} +
+ )} +
+ ); +} diff --git a/src/components/CreateKnowledgeBaseModal.jsx b/src/components/CreateKnowledgeBaseModal.jsx new file mode 100644 index 0000000..9dec1a5 --- /dev/null +++ b/src/components/CreateKnowledgeBaseModal.jsx @@ -0,0 +1,284 @@ +import React, { useState, useEffect } from 'react'; +import SvgIcon from './SvgIcon'; + +// 部门和组别的映射关系 +const departmentGroups = { + 技术部: ['开发组', '测试组', '运维组', '架构组', '安全组'], + 产品部: ['产品规划组', '用户研究组', '交互设计组', '项目管理组'], + 市场部: ['品牌推广组', '市场调研组', '客户关系组', '社交媒体组'], + 行政部: ['人事组', '财务组', '行政管理组', '后勤组'], +}; + +// 部门列表 +const departments = Object.keys(departmentGroups); + +/** + * 创建知识库模态框组件 + * @param {Object} props + * @param {boolean} props.show - 是否显示弹窗 + * @param {Object} props.formData - 表单数据 + * @param {Object} props.formErrors - 表单错误信息 + * @param {boolean} props.isSubmitting - 是否正在提交 + * @param {Function} props.onClose - 关闭弹窗的回调函数 + * @param {Function} props.onChange - 表单输入变化的回调函数 + * @param {Function} props.onSubmit - 提交表单的回调函数 + * @param {Object} props.currentUser - 当前用户信息 + */ +const CreateKnowledgeBaseModal = ({ + show, + formData, + formErrors, + isSubmitting, + onClose, + onChange, + onSubmit, + currentUser, +}) => { + // 根据用户角色确定可以创建的知识库类型 + const isAdmin = currentUser?.role === 'admin'; + const isLeader = currentUser?.role === 'leader'; + + // 获取当前用户的部门和组别 + const userDepartment = currentUser?.department || ''; + + // 可选的组别列表 + const [availableGroups, setAvailableGroups] = useState([]); + + // 当部门变化时更新可用的组别 + useEffect(() => { + if (formData.department && departmentGroups[formData.department]) { + setAvailableGroups(departmentGroups[formData.department]); + } else { + setAvailableGroups([]); + } + }, [formData.department]); + + // 提前返回需要放在所有 Hooks 之后 + if (!show) return null; + + // 获取当前用户可以创建的知识库类型 + const getAvailableTypes = () => { + if (isAdmin) { + return [ + { value: 'admin', label: '公共知识库' }, + { value: 'leader', label: 'Leader 级知识库' }, + { value: 'member', label: 'Member 级知识库' }, + { value: 'private', label: '私有知识库' }, + { value: 'secret', label: '保密知识库' }, + ]; + } else if (isLeader) { + return [ + { value: 'admin', label: '公共知识库' }, + { value: 'member', label: 'Member 级知识库' }, + { value: 'private', label: '私有知识库' }, + ]; + } else { + return [ + { value: 'admin', label: '公共知识库' }, + { value: 'private', label: '私有知识库' }, + ]; + } + }; + + const availableTypes = getAvailableTypes(); + + // 判断是否需要选择组别 + const isMemberTypeSelected = formData.type === 'member'; + const needSelectGroup = isMemberTypeSelected; + + return ( +
+
+
+
新建知识库
+ +
+
+
+ + + {formErrors.name &&
{formErrors.name}
} +
+
+ + + {formErrors.desc &&
{formErrors.desc}
} +
+
+ +
+ {availableTypes.map((type, index) => ( +
+ + +
+ ))} +
+ {!isAdmin && !isLeader && ( + + 您可以创建公共知识库(所有人可访问)或私有知识库(仅自己可访问)。 + + )} + {formErrors.type &&
{formErrors.type}
} +
+ + {/* 仅当不是私有知识库时才显示部门选项 */} + {formData.type !== 'private' && ( +
+ + {isAdmin ? ( + // 管理员可以选择任意部门 + + ) : ( + // 非管理员显示只读字段 + + )} + {formErrors.department && ( +
{formErrors.department}
+ )} +
+ )} + + {/* 仅当不是私有知识库时才显示组别选项 */} + {formData.type !== 'private' && ( +
+ + {isAdmin || (isLeader && needSelectGroup) ? ( + // 管理员可以选择任意组别,组长只能选择自己部门下的组别 + + ) : ( + // 普通用户显示只读字段 + + )} + {formErrors.group &&
{formErrors.group}
} +
+ )} +
+
+ + +
+
+
+ ); +}; + +export default CreateKnowledgeBaseModal; diff --git a/src/components/NotificationCenter.jsx b/src/components/NotificationCenter.jsx new file mode 100644 index 0000000..dbf5372 --- /dev/null +++ b/src/components/NotificationCenter.jsx @@ -0,0 +1,310 @@ +import React, { useState, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + clearNotifications, + markAllNotificationsAsRead, + markNotificationAsRead, + setWebSocketConnected, +} from '../store/notificationCenter/notificationCenter.slice'; +import RequestDetailSlideOver from '../pages/Permissions/components/RequestDetailSlideOver'; +import { approvePermissionThunk, rejectPermissionThunk } from '../store/permissions/permissions.thunks'; +import { showNotification } from '../store/notification.slice'; +import { initWebSocket, acknowledgeNotification, closeWebSocket } from '../services/websocket'; + +export default function NotificationCenter({ show, onClose }) { + const [showAll, setShowAll] = useState(false); + const dispatch = useDispatch(); + const { notifications, unreadCount, isConnected } = useSelector((state) => state.notificationCenter); + const [selectedRequest, setSelectedRequest] = useState(null); + const [showSlideOver, setShowSlideOver] = useState(false); + const [showResponseInput, setShowResponseInput] = useState(false); + const [currentRequestId, setCurrentRequestId] = useState(null); + const [isApproving, setIsApproving] = useState(false); + const [responseMessage, setResponseMessage] = useState(''); + const { isAuthenticated } = useSelector((state) => state.auth); + + const displayedNotifications = showAll ? notifications : notifications.slice(0, 5); + + // 初始化WebSocket连接 + useEffect(() => { + // 只有在用户已登录的情况下才连接WebSocket + if (isAuthenticated && !isConnected) { + initWebSocket() + .then(() => { + dispatch(setWebSocketConnected(true)); + console.log('Successfully connected to notification WebSocket'); + }) + .catch((error) => { + console.error('Failed to connect to notification WebSocket:', error); + dispatch(setWebSocketConnected(false)); + // 可以在这里显示连接失败的通知 + dispatch( + showNotification({ + message: '通知服务连接失败,部分功能可能不可用', + type: 'warning', + }) + ); + }); + } + + // 组件卸载时关闭WebSocket连接 + return () => { + if (isConnected) { + closeWebSocket(); + dispatch(setWebSocketConnected(false)); + } + }; + }, [isAuthenticated, isConnected, dispatch]); + + const handleClearAll = () => { + dispatch(clearNotifications()); + }; + + const handleMarkAllAsRead = () => { + dispatch(markAllNotificationsAsRead()); + }; + + const handleMarkAsRead = (notificationId) => { + dispatch(markNotificationAsRead(notificationId)); + // 同时发送确认消息到服务器 + acknowledgeNotification(notificationId); + }; + + const handleViewDetail = (notification) => { + // 标记为已读 + if (!notification.isRead) { + handleMarkAsRead(notification.id); + } + + if (notification.type === 'permission') { + setSelectedRequest(notification); + setShowSlideOver(true); + } + }; + + const handleCloseSlideOver = () => { + setShowSlideOver(false); + setTimeout(() => { + setSelectedRequest(null); + }, 300); + }; + + const handleOpenResponseInput = (requestId, approving) => { + setCurrentRequestId(requestId); + setIsApproving(approving); + setShowResponseInput(true); + }; + + const handleCloseResponseInput = () => { + setShowResponseInput(false); + setCurrentRequestId(null); + setResponseMessage(''); + }; + + const handleProcessRequest = () => { + if (!currentRequestId) return; + + const params = { + id: currentRequestId, + responseMessage, + }; + + if (isApproving) { + dispatch(approvePermissionThunk(params)); + } else { + dispatch(rejectPermissionThunk(params)); + } + }; + + if (!show) return null; + + return ( + <> +
+
+
+
通知中心
+ {unreadCount > 0 && {unreadCount}} + {isConnected ? ( + 已连接 + ) : ( + 未连接 + )} +
+
+ + + +
+
+
+ {displayedNotifications.length === 0 ? ( +
+ +

暂无通知

+
+ ) : ( + displayedNotifications.map((notification) => ( +
+
+
+ +
+
+
+
+ {notification.title} +
+ {notification.time} +
+

{notification.content}

+
+ {notification.hasDetail && ( + + )} + {!notification.isRead && ( + + )} +
+
+
+
+ )) + )} +
+ {notifications.length > 5 && ( +
+ +
+ )} +
+ + {/* 使用滑动面板组件 */} + handleOpenResponseInput(id, true)} + onReject={(id) => handleOpenResponseInput(id, false)} + processingId={currentRequestId} + approveRejectStatus={showResponseInput ? 'loading' : 'idle'} + isApproving={isApproving} + /> + + {/* 回复输入弹窗 */} + {showResponseInput && ( +
+
+
+
+
{isApproving ? '批准' : '拒绝'}申请
+ +
+
+
+ + +
+
+
+ + +
+
+
+
+
+ )} + + ); +} diff --git a/src/components/Pagination.jsx b/src/components/Pagination.jsx new file mode 100644 index 0000000..e16ab79 --- /dev/null +++ b/src/components/Pagination.jsx @@ -0,0 +1,59 @@ +import React from 'react'; + +/** + * 分页组件 + * @param {Object} props + * @param {number} props.currentPage - 当前页码 + * @param {number} props.totalPages - 总页数 + * @param {number} props.pageSize - 每页显示的条目数 + * @param {Function} props.onPageChange - 页码变化的回调函数 + * @param {Function} props.onPageSizeChange - 每页条目数变化的回调函数 + */ +const Pagination = ({ currentPage, totalPages, pageSize, onPageChange, onPageSizeChange }) => { + return ( +
+
+ +
+ +
+ ); +}; + +export default Pagination; diff --git a/src/components/SearchBar.jsx b/src/components/SearchBar.jsx new file mode 100644 index 0000000..c6ae245 --- /dev/null +++ b/src/components/SearchBar.jsx @@ -0,0 +1,193 @@ +import React, { useRef, useState, useEffect } from 'react'; +import SvgIcon from './SvgIcon'; + +/** + * 搜索栏组件 + * @param {Object} props + * @param {string} props.searchKeyword - 搜索关键词 + * @param {boolean} props.isSearching - 是否正在搜索 + * @param {Function} props.onSearchChange - 搜索关键词变化的回调函数 + * @param {Function} props.onSearch - 提交搜索的回调函数 + * @param {Function} props.onClearSearch - 清除搜索的回调函数 + * @param {string} props.placeholder - 搜索框占位文本 + * @param {string} props.className - 额外的 CSS 类名 + * @param {Array} props.searchResults - 搜索结果 + * @param {boolean} props.isSearchLoading - 搜索是否正在加载 + * @param {Function} props.onResultClick - 点击搜索结果的回调 + * @param {Function} props.onRequestAccess - 申请权限的回调 + */ +const SearchBar = ({ + searchKeyword, + isSearching, + onSearchChange, + onSearch, + onClearSearch, + placeholder = '搜索...', + className = 'w-50', + searchResults = [], + isSearchLoading = false, + onResultClick, + onRequestAccess, +}) => { + const [showDropdown, setShowDropdown] = useState(false); + const searchRef = useRef(null); + const inputRef = useRef(null); + + // 处理点击外部关闭下拉框 + useEffect(() => { + const handleClickOutside = (event) => { + if (searchRef.current && !searchRef.current.contains(event.target)) { + setShowDropdown(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + // 只有在用户执行搜索后且有结果时显示下拉框 + useEffect(() => { + if (isSearching && searchResults.length > 0) { + setShowDropdown(true); + } + }, [isSearching, searchResults]); + + // 处理输入变化 + const handleInputChange = (e) => { + onSearchChange(e); + }; + + // 处理搜索提交 + const handleSubmit = (e) => { + e.preventDefault(); + onSearch(e); + // 搜索提交后,如果有关键词,将显示下拉框(由searchResults决定) + }; + + return ( +
+
+
+ + {searchKeyword.trim() && ( + + )} + +
+
+ + {/* 搜索结果下拉框 - 仅在用户搜索且有结果时显示 */} + {showDropdown && (isSearchLoading || searchResults?.length > 0) && ( +
+
+ {isSearchLoading ? ( +
+
+ 加载中... +
+ 搜索中... +
+ ) : searchResults?.length > 0 ? ( + <> +
+ 搜索结果 ({searchResults.length}) +
+ {searchResults.map((item) => ( +
+
+
{ + if (item.permissions?.can_read) { + onResultClick(item.id, item.permissions); + setShowDropdown(false); + } + }} + > +
+ + + {item.highlighted_name ? ( + + ) : ( + item.name + )} + +
+
+ + {item.type === 'private' ? '私有' : item.type} + + {item.department && {item.department}} + {!item.permissions?.can_read && ( + + + 无权限 + + )} +
+
+ {!item.permissions?.can_read && ( + + )} +
+
+ ))} + + ) : ( +
未找到匹配的知识库
+ )} +
+
+ )} +
+ ); +}; + +export default SearchBar; diff --git a/src/components/Snackbar.jsx b/src/components/Snackbar.jsx index 1b84c48..8930889 100644 --- a/src/components/Snackbar.jsx +++ b/src/components/Snackbar.jsx @@ -1,4 +1,5 @@ import React, { useEffect } from 'react'; +import SvgIcon from './SvgIcon'; const Snackbar = ({ type = 'primary', message, duration = 3000, onClose }) => { if (!message) return null; @@ -10,7 +11,7 @@ const Snackbar = ({ type = 'primary', message, duration = 3000, onClose }) => { }, duration); return () => clearTimeout(timer); } - }, [duration, onClose]); + }, [message, duration, onClose]); const icons = { success: 'check-circle-fill', @@ -19,43 +20,21 @@ const Snackbar = ({ type = 'primary', message, duration = 3000, onClose }) => { danger: 'exclamation-triangle-fill', }; - return ( - <> - - - - - - - - - - - + // 处理关闭按钮点击 + const handleClose = (e) => { + e.preventDefault(); + if (onClose) onClose(); + }; -
- - - -
{message}
- -
- + return ( +
+ +
{message}
+ +
); }; diff --git a/src/components/SvgIcon.jsx b/src/components/SvgIcon.jsx new file mode 100644 index 0000000..61d2b0c --- /dev/null +++ b/src/components/SvgIcon.jsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { icons } from '../icons/icons'; + +export default function SvgIcon({ className, width, height, color, style }) { + // Create a new SVG string with custom attributes if provided + const customizeSvg = (svgString) => { + if (!svgString) return ''; + + // If no customization needed, return the original SVG + if (!width && !height && !color) return svgString; + + // Parse the SVG to modify attributes + let modifiedSvg = svgString; + + // Replace width if provided + if (width) { + modifiedSvg = modifiedSvg.replace(/width=['"]([^'"]*)['"]/g, `width="${width}"`); + } + + // Replace height if provided + if (height) { + modifiedSvg = modifiedSvg.replace(/height=['"]([^'"]*)['"]/g, `height="${height}"`); + } + + // Replace fill color if provided + if (color) { + modifiedSvg = modifiedSvg.replace(/fill=['"]currentColor['"]/g, `fill="${color}"`); + } + + return modifiedSvg; + }; + + return ( + + ); +} diff --git a/src/components/UserSettingsModal.jsx b/src/components/UserSettingsModal.jsx new file mode 100644 index 0000000..73a0828 --- /dev/null +++ b/src/components/UserSettingsModal.jsx @@ -0,0 +1,101 @@ +import React, { useState } from 'react'; +import { useSelector } from 'react-redux'; +import '../styles/style.scss'; + +export default function UserSettingsModal({ show, onClose }) { + const { user } = useSelector((state) => state.auth); + const [lastPasswordChange] = useState('30天前'); // This would come from backend in real app + + if (!show) return null; + + return ( +
+
+
+
+
管理员个人设置
+ +
+
+
+
个人信息
+
+ + +
+
+ + +
+
+ +
+
安全设置
+
+
+
+ + 修改密码 +
+ 上次修改:{lastPasswordChange} +
+ +
+
+
+
+ + 双重认证 +
+ 增强账户安全性 +
+ +
+
+ +
+
通知设置
+
+ + +
新的数据集访问申请通知
+
+
+ + +
异常登录和权限变更提醒
+
+
+
+
+ + +
+
+
+
+ ); +} diff --git a/src/icons/icons.js b/src/icons/icons.js new file mode 100644 index 0000000..364ba22 --- /dev/null +++ b/src/icons/icons.js @@ -0,0 +1,123 @@ +export const icons = { + plus: ` + + `, + 'more-dot': ` + + `, + trash: ` + + + `, + 'check-circle-fill': ` + + `, + 'info-fill': ` + + `, + 'exclamation-triangle-fill': ` + + `, + file: ` + + `, + clock: ` + + `, + 'circle-yes': ` + + `, + eye: ` + + `, + 'chat-dot': ` + + `, + key: ` + + `, + lock: ` + + `, + 'stack-fill': ` + + `, + edit: ``, + list: ` + + `, + 'setting-fill': ``, + dataset: ``, + calendar: ``, + clipboard: ``, + chat: ``, + 'arrowup-upload': ``, + send: ``, + search: ``, + bell: ``, + 'magnifying-glass': ``, + close: ` + + `, + 'knowledge-base': ` + + + + `, + 'knowledge-base-large': ` + + + + `, + plus: ` + + `, +}; diff --git a/src/layouts/HeaderWithNav.jsx b/src/layouts/HeaderWithNav.jsx index 835935f..a843cf0 100644 --- a/src/layouts/HeaderWithNav.jsx +++ b/src/layouts/HeaderWithNav.jsx @@ -1,84 +1,170 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Link, useNavigate, useLocation } from 'react-router-dom'; +import { logoutThunk } from '../store/auth/auth.thunk'; +import UserSettingsModal from '../components/UserSettingsModal'; +import NotificationCenter from '../components/NotificationCenter'; +import SvgIcon from '../components/SvgIcon'; export default function HeaderWithNav() { + const dispatch = useDispatch(); + const navigate = useNavigate(); + const location = useLocation(); + const { user } = useSelector((state) => state.auth); + const [showSettings, setShowSettings] = useState(false); + const [showNotifications, setShowNotifications] = useState(false); + const { notifications, unreadCount, isConnected } = useSelector((state) => state.notificationCenter); + + const handleLogout = async () => { + try { + await dispatch(logoutThunk()).unwrap(); + sessionStorage.removeItem('token'); + navigate('/login'); + } catch (error) {} + }; + + // Check if the current path starts with the given path + const isActive = (path) => { + return location.pathname.startsWith(path); + }; + console.log('user', user); + + // 检查用户是否有管理权限(leader 或 admin) + const hasManagePermission = user && (user.role === 'leader' || user.role === 'admin'); + return ( -
-
-
- +
-
+ + setShowSettings(false)} /> + setShowNotifications(false)} />
); } diff --git a/src/layouts/Mainlayout.jsx b/src/layouts/Mainlayout.jsx index 341d730..76ef448 100644 --- a/src/layouts/Mainlayout.jsx +++ b/src/layouts/Mainlayout.jsx @@ -7,7 +7,6 @@ export default function Mainlayout({ children }) { return ( <> - {children} ); diff --git a/src/main.jsx b/src/main.jsx index dbe6730..8203d0a 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -10,7 +10,7 @@ import { PersistGate } from 'redux-persist/integration/react'; import Loading from './components/Loading.jsx'; createRoot(document.getElementById('root')).render( - + // } persistor={persistor}> @@ -18,5 +18,5 @@ createRoot(document.getElementById('root')).render( - + // ); diff --git a/src/pages/Chat/Chat.jsx b/src/pages/Chat/Chat.jsx new file mode 100644 index 0000000..feed2d0 --- /dev/null +++ b/src/pages/Chat/Chat.jsx @@ -0,0 +1,158 @@ +import React, { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; +import { fetchChats, deleteChat, createChatRecord } from '../../store/chat/chat.thunks'; +import { showNotification } from '../../store/notification.slice'; +import ChatSidebar from './ChatSidebar'; +import NewChat from './NewChat'; +import ChatWindow from './ChatWindow'; + +export default function Chat() { + const { knowledgeBaseId, chatId } = useParams(); + const navigate = useNavigate(); + const dispatch = useDispatch(); + + // 从 Redux store 获取聊天记录列表 + const { + items: chatHistory, + status, + error, + } = useSelector((state) => state.chat.history || { items: [], status: 'idle', error: null }); + const operationStatus = useSelector((state) => state.chat.createSession?.status); + const operationError = useSelector((state) => state.chat.createSession?.error); + + // 获取聊天记录列表 + useEffect(() => { + dispatch(fetchChats({ page: 1, page_size: 20 })); + }, [dispatch]); + + // 监听操作状态,显示通知 + useEffect(() => { + if (operationStatus === 'succeeded') { + dispatch( + showNotification({ + message: '操作成功', + type: 'success', + }) + ); + } else if (operationStatus === 'failed' && operationError) { + dispatch( + showNotification({ + message: `操作失败: ${operationError}`, + type: 'danger', + }) + ); + } + }, [operationStatus, operationError, dispatch]); + + // If we have a knowledgeBaseId but no chatId, check if we have an existing chat or create a new one + useEffect(() => { + if (knowledgeBaseId && !chatId) { + // 检查是否存在包含此知识库的聊天记录 + const existingChat = chatHistory.find((chat) => { + // 检查知识库ID是否匹配 + if (chat.datasets && Array.isArray(chat.datasets)) { + return chat.datasets.some((ds) => ds.id === knowledgeBaseId); + } + // 兼容旧格式 + if (chat.dataset_id_list && Array.isArray(chat.dataset_id_list)) { + return chat.dataset_id_list.includes(knowledgeBaseId.replace(/-/g, '')); + } + return false; + }); + console.log('existingChat', existingChat); + + if (existingChat) { + // 找到现有聊天记录,导航到该聊天页面 + navigate(`/chat/${knowledgeBaseId}/${existingChat.conversation_id}`); + } else { + // 创建新聊天 + dispatch( + createChatRecord({ + dataset_id_list: [knowledgeBaseId.replace(/-/g, '')], + question: '选择当前知识库,创建聊天', + }) + ) + .unwrap() + .then((response) => { + // 创建成功,使用返回的conversation_id导航 + if (response && response.conversation_id) { + navigate(`/chat/${knowledgeBaseId}/${response.conversation_id}`); + } else { + // 错误处理 + dispatch( + showNotification({ + message: '创建聊天失败:未能获取会话ID', + type: 'danger', + }) + ); + } + }) + .catch((error) => { + dispatch( + showNotification({ + message: `创建聊天失败: ${error}`, + type: 'danger', + }) + ); + }); + } + } + }, [knowledgeBaseId, chatId, chatHistory, navigate, dispatch]); + + const handleDeleteChat = (id) => { + // 调用 Redux action 删除聊天 + dispatch(deleteChat(id)) + .unwrap() + .then(() => { + // 删除成功后显示通知 + dispatch( + showNotification({ + message: '聊天记录已删除', + type: 'success', + }) + ); + + // If the deleted chat is the current one, navigate to the chat list + if (chatId === id) { + navigate('/chat'); + } + }) + .catch((error) => { + // 删除失败显示错误通知 + dispatch( + showNotification({ + message: `删除失败: ${error}`, + type: 'danger', + }) + ); + }); + }; + + return ( +
+
+ {/* Sidebar */} +
+ +
+ + {/* Main Content */} +
+ {!chatId ? : } +
+
+
+ ); +} diff --git a/src/pages/Chat/ChatSidebar.jsx b/src/pages/Chat/ChatSidebar.jsx new file mode 100644 index 0000000..5e5bdba --- /dev/null +++ b/src/pages/Chat/ChatSidebar.jsx @@ -0,0 +1,143 @@ +import React, { useState } from 'react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import SvgIcon from '../../components/SvgIcon'; + +export default function ChatSidebar({ chatHistory = [], onDeleteChat, isLoading = false, hasError = false }) { + const navigate = useNavigate(); + const { chatId, knowledgeBaseId } = useParams(); + const [activeDropdown, setActiveDropdown] = useState(null); + + const handleNewChat = () => { + navigate('/chat'); + }; + + const handleMouseEnter = (id) => { + setActiveDropdown(id); + }; + + const handleMouseLeave = () => { + setActiveDropdown(null); + }; + + const handleDeleteChat = (e, id) => { + e.preventDefault(); + e.stopPropagation(); + if (onDeleteChat) { + onDeleteChat(id); + } + setActiveDropdown(null); + }; + + // 渲染加载状态 + const renderLoading = () => ( +
+
+ 加载中... +
+
加载聊天记录...
+
+ ); + + // 渲染错误状态 + const renderError = () => ( +
+
+ +
+
加载聊天记录失败,请重试
+
+ ); + + // 渲染空状态 + const renderEmpty = () => ( +
+
暂无聊天记录
+
+ ); + + return ( +
+
+
聊天记录
+
+ +
+ +
+ +
+ {isLoading ? ( + renderLoading() + ) : hasError ? ( + renderError() + ) : chatHistory.length === 0 ? ( + renderEmpty() + ) : ( +
    + {chatHistory.map((chat) => ( +
  • + +
    +
    + {chat.datasets?.map((ds) => ds.name).join(', ') || '未命名知识库'} +
    +
    + {chat.last_message + ? chat.last_message.length > 30 + ? chat.last_message.substring(0, 30) + '...' + : chat.last_message + : '新对话'} +
    +
    + {chat.last_time && new Date(chat.last_time).toLocaleDateString()} +
    +
    + +
    handleMouseEnter(chat.conversation_id)} + onMouseLeave={handleMouseLeave} + > + + {activeDropdown === chat.conversation_id && ( +
    + +
    + )} +
    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/pages/Chat/ChatWindow.jsx b/src/pages/Chat/ChatWindow.jsx new file mode 100644 index 0000000..e80659c --- /dev/null +++ b/src/pages/Chat/ChatWindow.jsx @@ -0,0 +1,297 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { fetchMessages } from '../../store/chat/chat.messages.thunks'; +import { resetMessages, resetSendMessageStatus, addMessage } from '../../store/chat/chat.slice'; +import { showNotification } from '../../store/notification.slice'; +import { createChatRecord, fetchAvailableDatasets, fetchConversationDetail } from '../../store/chat/chat.thunks'; +import { fetchKnowledgeBases } from '../../store/knowledgeBase/knowledgeBase.thunks'; +import SvgIcon from '../../components/SvgIcon'; +import { get } from '../../services/api'; + +export default function ChatWindow({ chatId, knowledgeBaseId }) { + const dispatch = useDispatch(); + const [inputMessage, setInputMessage] = useState(''); + const [loading, setLoading] = useState(false); + const messagesEndRef = useRef(null); + + // 从 Redux store 获取消息 + const messages = useSelector((state) => state.chat.messages.items); + const messageStatus = useSelector((state) => state.chat.messages.status); + const messageError = useSelector((state) => state.chat.messages.error); + const { status: sendStatus, error: sendError } = useSelector((state) => state.chat.sendMessage); + + // 使用新的Redux状态结构 + const knowledgeBases = useSelector((state) => state.knowledgeBase.knowledgeBases || []); + const knowledgeBase = knowledgeBases.find((kb) => kb.id === knowledgeBaseId); + const isLoadingKnowledgeBases = useSelector((state) => state.knowledgeBase.loading); + + // 获取可用数据集列表 + const availableDatasets = useSelector((state) => state.chat.availableDatasets.items || []); + const availableDatasetsLoading = useSelector((state) => state.chat.availableDatasets.status === 'loading'); + + // 获取会话详情 + const conversation = useSelector((state) => state.chat.currentChat.data); + const conversationStatus = useSelector((state) => state.chat.currentChat.status); + const conversationError = useSelector((state) => state.chat.currentChat.error); + + // 获取聊天详情 + useEffect(() => { + if (chatId) { + setLoading(true); + dispatch(fetchConversationDetail(chatId)) + .unwrap() + .catch((error) => { + // 如果是新聊天,API会返回404,此时不显示错误 + if (error && error !== 'Error: Request failed with status code 404') { + dispatch( + showNotification({ + message: `获取聊天详情失败: ${error || '未知错误'}`, + type: 'danger', + }) + ); + } + }) + .finally(() => { + setLoading(false); + }); + } + + // 组件卸载时清空消息 + return () => { + dispatch(resetMessages()); + }; + }, [chatId, dispatch]); + + // 新会话自动添加欢迎消息 + useEffect(() => { + // 如果是新聊天且没有任何消息,添加一条系统欢迎消息 + if (chatId && messages.length === 0 && !loading && messageStatus !== 'loading') { + const selectedKb = knowledgeBase || + availableDatasets.find((ds) => ds.id === knowledgeBaseId) || { name: '知识库' }; + + dispatch( + addMessage({ + id: 'welcome-' + Date.now(), + role: 'assistant', + content: `欢迎使用${selectedKb.name}。您可以向我提问任何相关问题。`, + created_at: new Date().toISOString(), + }) + ); + } + }, [chatId, messages.length, loading, messageStatus, knowledgeBase, knowledgeBaseId, availableDatasets, dispatch]); + + // 监听发送消息状态 + useEffect(() => { + if (sendStatus === 'failed' && sendError) { + dispatch( + showNotification({ + message: `发送失败: ${sendError}`, + type: 'danger', + }) + ); + dispatch(resetSendMessageStatus()); + } + }, [sendStatus, sendError, dispatch]); + + // 滚动到底部 + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + // 获取当前会话的知识库信息 + useEffect(() => { + // 如果conversation有数据集信息,优先使用它 + if (conversation && conversation.datasets && conversation.datasets.length > 0) { + return; + } + + // 如果没有会话数据集信息,但有knowledgeBaseId,尝试从知识库列表中查找 + if (knowledgeBaseId && knowledgeBases.length === 0 && !availableDatasets.length) { + dispatch(fetchAvailableDatasets()); + } + }, [dispatch, knowledgeBaseId, knowledgeBases, conversation, availableDatasets]); + + const handleSendMessage = (e) => { + e.preventDefault(); + + if (!inputMessage.trim() || sendStatus === 'loading') return; + + // 获取知识库ID列表 + let dataset_id_list = []; + + if (conversation && conversation.datasets) { + // 如果已有会话,使用会话中的知识库 + dataset_id_list = conversation.datasets.map((ds) => ds.id.replace(/-/g, '')); + } else if (knowledgeBaseId) { + // 如果是新会话,使用当前选择的知识库 + dataset_id_list = [knowledgeBaseId.replace(/-/g, '')]; + } else if (availableDatasets.length > 0) { + // 如果都没有,尝试使用可用知识库列表中的第一个 + dataset_id_list = [availableDatasets[0].id.replace(/-/g, '')]; + } + + if (dataset_id_list.length === 0) { + dispatch( + showNotification({ + message: '发送失败:未选择知识库', + type: 'danger', + }) + ); + return; + } + + // 发送消息到服务器 + dispatch( + createChatRecord({ + dataset_id_list: dataset_id_list, + question: inputMessage, + conversation_id: chatId, + }) + ) + .unwrap() + .then(() => { + // 成功发送后,可以执行任何需要的操作 + // 例如:在用户发送第一条消息后更新URL中的会话ID + }) + .catch((error) => { + // 发送失败,显示错误信息 + dispatch( + showNotification({ + message: `发送失败: ${error}`, + type: 'danger', + }) + ); + }); + + // 清空输入框 + setInputMessage(''); + }; + + // 渲染加载状态 + const renderLoading = () => ( +
+
+ 加载中... +
+
加载聊天记录...
+
+ ); + + // 渲染错误状态 + const renderError = () => ( +
+

+ 加载消息失败 +

+

{messageError}

+ +
+ ); + + // 渲染空消息状态 + const renderEmpty = () => { + if (loading) return null; + + return ( +
+

暂无消息,开始发送第一条消息吧

+
+ ); + }; + + return ( +
+ {/* Chat header */} +
+ {conversation && conversation.datasets ? ( + <> +
{conversation.datasets.map((dataset) => dataset.name).join(', ')}
+ {conversation.datasets.length > 0 && conversation.datasets[0].type && ( + 类型: {conversation.datasets[0].type} + )} + + ) : knowledgeBase ? ( + <> +
{knowledgeBase.name}
+ {knowledgeBase.description} + + ) : ( +
{loading || availableDatasetsLoading ? '加载中...' : '聊天'}
+ )} +
+ + {/* Chat messages */} +
+
+ {messageStatus === 'loading' + ? renderLoading() + : messageStatus === 'failed' + ? renderError() + : messages.length === 0 + ? renderEmpty() + : messages.map((message) => ( +
+
+
{message.content}
+
+
+ {message.created_at && new Date(message.created_at).toLocaleTimeString()} +
+
+ ))} + + {sendStatus === 'loading' && ( +
+
+
+ 加载中... +
+
+
+ )} + +
+
+
+ + {/* Chat input */} +
+
+ setInputMessage(e.target.value)} + disabled={sendStatus === 'loading'} + /> + +
+
+
+ ); +} diff --git a/src/pages/Chat/NewChat.jsx b/src/pages/Chat/NewChat.jsx new file mode 100644 index 0000000..bf81e5b --- /dev/null +++ b/src/pages/Chat/NewChat.jsx @@ -0,0 +1,112 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; +import { showNotification } from '../../store/notification.slice'; +import { fetchAvailableDatasets, fetchChats, createChatRecord } from '../../store/chat/chat.thunks'; +import SvgIcon from '../../components/SvgIcon'; + +export default function NewChat() { + const navigate = useNavigate(); + const dispatch = useDispatch(); + + // 从 Redux store 获取可用知识库数据 + const datasets = useSelector((state) => state.chat.availableDatasets.items || []); + const isLoading = useSelector((state) => state.chat.availableDatasets.status === 'loading'); + const error = useSelector((state) => state.chat.availableDatasets.error); + + // 获取聊天历史记录 + const chatHistory = useSelector((state) => state.chat.history.items || []); + const chatHistoryLoading = useSelector((state) => state.chat.history.status === 'loading'); + + // 获取可用知识库列表和聊天历史 + useEffect(() => { + dispatch(fetchAvailableDatasets()); + dispatch(fetchChats({ page: 1, page_size: 50 })); + }, [dispatch]); + + // 监听错误状态 + useEffect(() => { + if (error) { + dispatch( + showNotification({ + message: `获取可用知识库列表失败: ${error}`, + type: 'danger', + }) + ); + } + }, [error, dispatch]); + + // 处理知识库选择 + const handleSelectKnowledgeBase = (dataset) => { + // 判断聊天历史中是否已存在该知识库的聊天记录 + const existingChat = chatHistory.find((chat) => { + // 检查知识库ID是否匹配 + if (chat.datasets && Array.isArray(chat.datasets)) { + return chat.datasets.some((ds) => ds.id === dataset.id); + } + // 兼容旧格式 + if (chat.dataset_id_list && Array.isArray(chat.dataset_id_list)) { + return chat.dataset_id_list.includes(dataset.id.replace(/-/g, '')); + } + return false; + }); + + if (existingChat) { + // 找到现有聊天记录,导航到该聊天页面 + navigate(`/chat/${dataset.id}/${existingChat.conversation_id}`); + } else { + // 没有找到现有聊天记录,创建新的聊天 + navigate(`/chat/${dataset.id}`); + } + }; + + // 渲染加载状态 + if (isLoading || chatHistoryLoading) { + return ( +
+
+ 加载中... +
+
+ ); + } + + return ( +
+

选择知识库开始聊天

+
+ {datasets.length > 0 ? ( + datasets.map((dataset) => ( +
+
handleSelectKnowledgeBase(dataset)} + > +
+
{dataset.name}
+

{dataset.desc || dataset.description || ''}

+
+ + + {dataset.document_count || 0} 文档 + + + + {dataset.create_time + ? new Date(dataset.create_time).toLocaleDateString() + : 'N/A'} + +
+
+
+
+ )) + ) : ( +
+
暂无可访问的知识库,请先申请知识库访问权限
+
+ )} +
+
+ ); +} diff --git a/src/pages/KnowledgeBase/Detail/DatasetTab.jsx b/src/pages/KnowledgeBase/Detail/DatasetTab.jsx new file mode 100644 index 0000000..ae4b1e8 --- /dev/null +++ b/src/pages/KnowledgeBase/Detail/DatasetTab.jsx @@ -0,0 +1,374 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useDispatch } from 'react-redux'; +import { showNotification } from '../../../store/notification.slice'; +import SvgIcon from '../../../components/SvgIcon'; + +// 导入拆分的组件 +import Breadcrumb from './components/Breadcrumb'; +import DocumentList from './components/DocumentList'; +import FileUploadModal from './components/FileUploadModal'; + +export default function DatasetTab({ knowledgeBase }) { + const dispatch = useDispatch(); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedDocuments, setSelectedDocuments] = useState([]); + const [selectAll, setSelectAll] = useState(false); + const [showBatchDropdown, setShowBatchDropdown] = useState(false); + const [showAddFileModal, setShowAddFileModal] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [newFile, setNewFile] = useState({ + name: '', + description: '', + file: null, + }); + const [fileErrors, setFileErrors] = useState({}); + const dropdownRef = useRef(null); + const fileInputRef = useRef(null); + + // Use documents from knowledge base or empty array if not available + const [documents, setDocuments] = useState(knowledgeBase.documents || []); + + // Update documents when knowledgeBase changes + useEffect(() => { + setDocuments(knowledgeBase.documents || []); + }, [knowledgeBase]); + + // Handle click outside dropdown + useEffect(() => { + function handleClickOutside(event) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setShowBatchDropdown(false); + } + } + + // Add event listener + document.addEventListener('mousedown', handleClickOutside); + return () => { + // Remove event listener on cleanup + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [dropdownRef]); + + // Handle search input change + const handleSearchChange = (e) => { + setSearchQuery(e.target.value); + }; + + // Handle select all checkbox + const handleSelectAll = () => { + if (selectAll) { + setSelectedDocuments([]); + } else { + setSelectedDocuments(documents.map((doc) => doc.id)); + } + setSelectAll(!selectAll); + }; + + // Handle individual document selection + const handleSelectDocument = (docId) => { + if (selectedDocuments.includes(docId)) { + setSelectedDocuments(selectedDocuments.filter((id) => id !== docId)); + setSelectAll(false); + } else { + setSelectedDocuments([...selectedDocuments, docId]); + if (selectedDocuments.length + 1 === documents.length) { + setSelectAll(true); + } + } + }; + + // Handle batch delete + const handleBatchDelete = () => { + if (selectedDocuments.length === 0) return; + + // Here you would typically call an API to delete the selected documents + console.log('Deleting documents:', selectedDocuments); + + // Update documents state by removing selected documents + setDocuments((prevDocuments) => prevDocuments.filter((doc) => !selectedDocuments.includes(doc.id))); + + // Show notification + dispatch( + showNotification({ + message: '已删除选中的数据集', + type: 'success', + }) + ); + + // Reset selection + setSelectedDocuments([]); + setSelectAll(false); + setShowBatchDropdown(false); + }; + + // Handle file input change + const handleFileChange = (e) => { + const selectedFile = e.target.files[0]; + if (selectedFile) { + setNewFile({ + ...newFile, + name: selectedFile.name, + file: selectedFile, + }); + + // Clear file error if exists + if (fileErrors.file) { + setFileErrors((prev) => ({ + ...prev, + file: '', + })); + } + } + }; + + // Handle description input change + const handleDescriptionChange = (e) => { + setNewFile({ + ...newFile, + description: e.target.value, + }); + }; + + // Handle file drop + const handleFileDrop = (e) => { + e.preventDefault(); + e.stopPropagation(); + + const droppedFile = e.dataTransfer.files[0]; + if (droppedFile) { + setNewFile({ + ...newFile, + name: droppedFile.name, + file: droppedFile, + }); + + // Clear file error if exists + if (fileErrors.file) { + setFileErrors((prev) => ({ + ...prev, + file: '', + })); + } + } + }; + + // Prevent default behavior for drag events + const handleDragOver = (e) => { + e.preventDefault(); + e.stopPropagation(); + }; + + // Validate file form + const validateFileForm = () => { + const errors = {}; + + if (!newFile.file) { + errors.file = '请上传文件'; + } + + setFileErrors(errors); + return Object.keys(errors).length === 0; + }; + + // Handle file upload + const handleFileUpload = () => { + // Validate form + if (!validateFileForm()) { + return; + } + + setIsSubmitting(true); + + // Here you would typically call an API to upload the file + console.log('Uploading file:', newFile); + + // Simulate API call + setTimeout(() => { + // Generate a new ID for the document + const newId = Date.now().toString(); + + // Format file size + const fileSizeKB = newFile.file ? (newFile.file.size / 1024).toFixed(0) + 'kb' : '0kb'; + + // Get current date + const today = new Date(); + const formattedDate = today.toISOString(); + + // Create new document object + const newDocument = { + id: newId, + name: newFile.name, + description: newFile.description || '无描述', + size: fileSizeKB, + create_time: formattedDate, + update_time: formattedDate, + }; + + // Add new document to the documents array + setDocuments((prevDocuments) => [...prevDocuments, newDocument]); + + // Show notification + dispatch( + showNotification({ + message: '数据集上传成功', + type: 'success', + }) + ); + + setIsSubmitting(false); + // Reset form and close modal + handleCloseAddFileModal(); + }, 1000); + }; + + // Open file selector when clicking on the upload area + const handleUploadAreaClick = () => { + fileInputRef.current.click(); + }; + + // Handle close modal + const handleCloseAddFileModal = () => { + setNewFile({ + name: '', + description: '', + file: null, + }); + setFileErrors({}); + setShowAddFileModal(false); + }; + + // Handle delete document + const handleDeleteDocument = (docId) => { + // Here you would typically call an API to delete the document + console.log('Deleting document:', docId); + + // Update documents state by removing the deleted document + setDocuments((prevDocuments) => prevDocuments.filter((doc) => doc.id !== docId)); + + // Show notification + dispatch( + showNotification({ + message: '数据集已删除', + type: 'success', + }) + ); + }; + + // Filter documents based on search query + const filteredDocuments = documents.filter( + (doc) => + doc.name.toLowerCase().includes(searchQuery.toLowerCase()) || + (doc.description && doc.description.toLowerCase().includes(searchQuery.toLowerCase())) + ); + + return ( + <> + {/* Breadcrumb navigation */} + + + {/* Toolbar */} +
+
+ + {selectedDocuments.length > 0 && ( +
+ + {showBatchDropdown && ( +
    +
  • + +
  • +
+ )} +
+ )} +
+
+ +
+
+ + {/* Document list */} + + {/* Pagination */} +
+
+ 每页行数: + +
+
+ 1-5 of 10 + +
+
+ + {/* File upload modal */} + + + ); +} diff --git a/src/pages/KnowledgeBase/Detail/KnowledgeBaseDetail.jsx b/src/pages/KnowledgeBase/Detail/KnowledgeBaseDetail.jsx new file mode 100644 index 0000000..a986f25 --- /dev/null +++ b/src/pages/KnowledgeBase/Detail/KnowledgeBaseDetail.jsx @@ -0,0 +1,119 @@ +import React, { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useSelector, useDispatch } from 'react-redux'; +import { showNotification } from '../../../store/notification.slice'; +import { getKnowledgeBaseById } from '../../../store/knowledgeBase/knowledgeBase.thunks'; +import SvgIcon from '../../../components/SvgIcon'; +import DatasetTab from './DatasetTab'; +import SettingsTab from './SettingsTab'; + +export default function KnowledgeBaseDetail() { + const { id, tab } = useParams(); + const navigate = useNavigate(); + const dispatch = useDispatch(); + const [activeTab, setActiveTab] = useState(tab === 'settings' ? 'settings' : 'datasets'); + + // Get knowledge base details from Redux store - 使用新的状态结构 + const knowledgeBase = useSelector((state) => state.knowledgeBase.currentKnowledgeBase); + const loading = useSelector((state) => state.knowledgeBase.loading); + const error = useSelector((state) => state.knowledgeBase.error); + + // Fetch knowledge base details when component mounts or ID changes + useEffect(() => { + if (id) { + dispatch(getKnowledgeBaseById(id)); + } + }, [dispatch, id]); + + // Update active tab when URL changes + useEffect(() => { + if (tab) { + setActiveTab(tab === 'settings' ? 'settings' : 'datasets'); + } + }, [tab]); + + // If knowledge base not found, show notification and redirect + useEffect(() => { + if (!loading && error) { + dispatch( + showNotification({ + message: `获取知识库失败: ${error.message || '未找到知识库'}`, + type: 'warning', + }) + ); + navigate('/knowledge-base'); + } + }, [loading, error, dispatch, navigate]); + + // Handle tab change + const handleTabChange = (tab) => { + setActiveTab(tab); + navigate(`/knowledge-base/${id}/${tab}`); + }; + + // Show loading state if knowledge base not loaded yet + if (loading || !knowledgeBase) { + return ( +
+
+ 加载中... +
+
+ ); + } + + return ( +
+
+ {/* Sidebar */} + + + {/* Main content */} +
+ {/* Render the appropriate tab component */} + {activeTab === 'datasets' ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/src/pages/KnowledgeBase/Detail/SettingsTab.jsx b/src/pages/KnowledgeBase/Detail/SettingsTab.jsx new file mode 100644 index 0000000..ae5014b --- /dev/null +++ b/src/pages/KnowledgeBase/Detail/SettingsTab.jsx @@ -0,0 +1,348 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; +import { showNotification } from '../../../store/notification.slice'; +import { + updateKnowledgeBase, + deleteKnowledgeBase, + changeKnowledgeBaseType, +} from '../../../store/knowledgeBase/knowledgeBase.thunks'; + +// 导入拆分的组件 +import Breadcrumb from './components/Breadcrumb'; +import KnowledgeBaseForm from './components/KnowledgeBaseForm'; +import DeleteConfirmModal from './components/DeleteConfirmModal'; +import UserPermissionsManager from './components/UserPermissionsManager'; + +// 部门和组别的映射关系 +const departmentGroups = { + 技术部: ['开发组', '测试组', '运维组', '架构组', '安全组'], + 产品部: ['产品规划组', '用户研究组', '交互设计组', '项目管理组'], + 市场部: ['品牌推广组', '市场调研组', '客户关系组', '社交媒体组'], + 行政部: ['人事组', '财务组', '行政管理组', '后勤组'], +}; + +// 部门列表 +const departments = Object.keys(departmentGroups); + +export default function SettingsTab({ knowledgeBase }) { + const dispatch = useDispatch(); + const navigate = useNavigate(); + const currentUser = useSelector((state) => state.auth.user); + const isAdmin = currentUser?.role === 'admin'; + + // State for knowledge base form + const [knowledgeBaseForm, setKnowledgeBaseForm] = useState({ + name: knowledgeBase.name, + desc: knowledgeBase.desc || knowledgeBase.description || '', + type: knowledgeBase.type || 'private', // 默认为私有知识库 + original_type: knowledgeBase.type || 'private', // 存储原始类型用于比较 + department: knowledgeBase.department || '', + group: knowledgeBase.group || '', + original_department: knowledgeBase.department || '', + original_group: knowledgeBase.group || '', + }); + const [formErrors, setFormErrors] = useState({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [availableGroups, setAvailableGroups] = useState([]); + + // 当部门变化时,更新可选的组别 + useEffect(() => { + if (knowledgeBaseForm.department && departmentGroups[knowledgeBaseForm.department]) { + setAvailableGroups(departmentGroups[knowledgeBaseForm.department]); + // 如果已选择的组别不在新部门的选项中,则重置组别 + if (!departmentGroups[knowledgeBaseForm.department].includes(knowledgeBaseForm.group)) { + setKnowledgeBaseForm((prev) => ({ + ...prev, + group: '', + })); + } + } else { + setAvailableGroups([]); + setKnowledgeBaseForm((prev) => ({ + ...prev, + group: '', + })); + } + }, [knowledgeBaseForm.department]); + + // 初始化可用组别 + useEffect(() => { + if (knowledgeBase.department && departmentGroups[knowledgeBase.department]) { + setAvailableGroups(departmentGroups[knowledgeBase.department]); + } + }, [knowledgeBase]); + + // Handle knowledge base form input change + const handleInputChange = (e) => { + const { name, value } = e.target; + + // 检查如果是类型更改,确保用户有权限 + if (name === 'type') { + const role = currentUser?.role; + let allowed = false; + + // 根据角色判断可以选择的知识库类型 + if (role === 'admin') { + // 管理员可以选择任何类型 + allowed = ['admin', 'leader', 'member', 'private', 'secret'].includes(value); + } else if (role === 'leader') { + // 组长只能选择 member 和 private + allowed = ['member', 'private'].includes(value); + } else { + // 普通成员只能选择 private + allowed = value === 'private'; + } + + if (!allowed) { + dispatch( + showNotification({ + message: '您没有权限设置此类型的知识库', + type: 'warning', + }) + ); + return; + } + } + + setKnowledgeBaseForm((prev) => ({ + ...prev, + [name]: value, + })); + + // Clear error if exists + if (formErrors[name]) { + setFormErrors((prev) => ({ + ...prev, + [name]: '', + })); + } + }; + + // Validate knowledge base form + const validateForm = () => { + const errors = {}; + // 私有知识库不需要部门和组别 + const isPrivate = knowledgeBaseForm.type === 'private'; + + if (!knowledgeBaseForm.name.trim()) { + errors.name = '请输入知识库名称'; + } + + if (!knowledgeBaseForm.desc.trim()) { + errors.desc = '请输入知识库描述'; + } + + if (!knowledgeBaseForm.type) { + errors.type = '请选择知识库类型'; + } + + // 只有非私有知识库需要验证部门和组别 + if (!isPrivate) { + if (isAdmin && !knowledgeBaseForm.department) { + errors.department = '请选择部门'; + } + + if (isAdmin && !knowledgeBaseForm.group) { + errors.group = '请选择组别'; + } + } + + setFormErrors(errors); + return Object.keys(errors).length === 0; + }; + + // 检查是否有部门或组别变更 + const hasDepartmentOrGroupChanged = () => { + return ( + knowledgeBaseForm.department !== knowledgeBaseForm.original_department || + knowledgeBaseForm.group !== knowledgeBaseForm.original_group + ); + }; + + // 单独处理类型更改 + const handleTypeChange = (newType) => { + if (!currentUser) { + dispatch( + showNotification({ + message: '用户信息不完整,无法更改类型', + type: 'warning', + }) + ); + return; + } + + // 所有用户都可以修改为admin或private类型,无需额外检查 + if (newType !== 'admin' && newType !== 'private' && currentUser.role === 'member') { + dispatch( + showNotification({ + message: '您只能将知识库修改为公共(admin)或私有(private)类型', + type: 'warning', + }) + ); + return; + } + + if (isAdmin && !validateForm()) { + return; + } + + setIsSubmitting(true); + + // 私有知识库不需要部门和组别 + const isPrivate = newType === 'private'; + const department = isPrivate ? '' : isAdmin ? knowledgeBaseForm.department : currentUser.department || ''; + const group = isPrivate ? '' : isAdmin ? knowledgeBaseForm.group : currentUser.group || ''; + + dispatch( + changeKnowledgeBaseType({ + id: knowledgeBase.id, + type: newType, + department, + group, + }) + ) + .unwrap() + .then((updatedKB) => { + // 更新表单显示 + setKnowledgeBaseForm((prev) => ({ + ...prev, + type: updatedKB.type, + original_type: updatedKB.type, + department: updatedKB.department, + original_department: updatedKB.department, + group: updatedKB.group, + original_group: updatedKB.group, + })); + + dispatch( + showNotification({ + message: `知识库类型已更新为 ${updatedKB.type}`, + type: 'success', + }) + ); + setIsSubmitting(false); + }) + .catch((error) => { + dispatch( + showNotification({ + message: `类型更新失败: ${error.message || '未知错误'}`, + type: 'danger', + }) + ); + setIsSubmitting(false); + }); + }; + + // Handle form submission + const handleSubmit = (e) => { + e.preventDefault(); + + // Validate form + if (!validateForm()) { + return; + } + + setIsSubmitting(true); + + // 检查类型是否有更改,如果有,则使用单独的API更新类型 + if (knowledgeBaseForm.type !== knowledgeBaseForm.original_type || (isAdmin && hasDepartmentOrGroupChanged())) { + handleTypeChange(knowledgeBaseForm.type); + return; + } + + // Dispatch update knowledge base action (不包含类型更改) + dispatch( + updateKnowledgeBase({ + id: knowledgeBase.id, + data: { + name: knowledgeBaseForm.name, + desc: knowledgeBaseForm.desc, + description: knowledgeBaseForm.desc, // Add description field for compatibility + }, + }) + ) + .unwrap() + .then(() => { + dispatch( + showNotification({ + message: '知识库更新成功', + type: 'success', + }) + ); + setIsSubmitting(false); + }) + .catch((error) => { + dispatch( + showNotification({ + message: `更新失败: ${error.message || '未知错误'}`, + type: 'danger', + }) + ); + setIsSubmitting(false); + }); + }; + + // Handle knowledge base deletion + const handleDelete = () => { + setIsSubmitting(true); + + // Dispatch delete knowledge base action + dispatch(deleteKnowledgeBase(knowledgeBase.id)) + .unwrap() + .then(() => { + dispatch( + showNotification({ + message: '知识库已删除', + type: 'success', + }) + ); + // Navigate back to knowledge base list + navigate('/knowledge-base'); + }) + .catch((error) => { + dispatch( + showNotification({ + message: `删除失败: ${error.message || '未知错误'}`, + type: 'danger', + }) + ); + setIsSubmitting(false); + setShowDeleteConfirm(false); + }); + }; + + return ( + <> + {/* Breadcrumb navigation */} + + + {/* Knowledge Base Form */} + setShowDeleteConfirm(true)} + onTypeChange={handleTypeChange} + isAdmin={isAdmin} + departments={departments} + availableGroups={availableGroups} + /> + + {/* User Permissions Manager */} + {/* */} + + {/* Delete confirmation modal */} + setShowDeleteConfirm(false)} + onConfirm={handleDelete} + /> + + ); +} diff --git a/src/pages/KnowledgeBase/Detail/components/Breadcrumb.jsx b/src/pages/KnowledgeBase/Detail/components/Breadcrumb.jsx new file mode 100644 index 0000000..b3bc408 --- /dev/null +++ b/src/pages/KnowledgeBase/Detail/components/Breadcrumb.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +/** + * 面包屑导航组件 + */ +const Breadcrumb = ({ knowledgeBase, activeTab }) => { + return ( +
+ +
+ ); +}; + +export default Breadcrumb; diff --git a/src/pages/KnowledgeBase/Detail/components/DeleteConfirmModal.jsx b/src/pages/KnowledgeBase/Detail/components/DeleteConfirmModal.jsx new file mode 100644 index 0000000..e8550f1 --- /dev/null +++ b/src/pages/KnowledgeBase/Detail/components/DeleteConfirmModal.jsx @@ -0,0 +1,70 @@ +import React from 'react'; + +/** + * 删除确认模态框组件 + */ +const DeleteConfirmModal = ({ show, title, isSubmitting, onCancel, onConfirm }) => { + if (!show) return null; + + return ( +
+
+
+
确认删除
+ +
+
+

您确定要删除知识库 "{title}" 吗?此操作不可撤销。

+
+
+ + +
+
+
+ ); +}; + +export default DeleteConfirmModal; diff --git a/src/pages/KnowledgeBase/Detail/components/DocumentList.jsx b/src/pages/KnowledgeBase/Detail/components/DocumentList.jsx new file mode 100644 index 0000000..747832a --- /dev/null +++ b/src/pages/KnowledgeBase/Detail/components/DocumentList.jsx @@ -0,0 +1,72 @@ +import React from 'react'; +import SvgIcon from '../../../../components/SvgIcon'; + +/** + * 文档列表组件 + */ +const DocumentList = ({ documents, selectedDocuments, onSelectAll, onSelectDocument, onDeleteDocument, selectAll }) => { + if (documents.length === 0) { + return
暂无数据集,请上传数据集
; + } + + return ( +
+ + + + + + + + + + + + + {documents.map((doc) => ( + + + + + + + + + ))} + +
+
+ +
+
名称描述大小更新时间 + 操作 +
+
+ onSelectDocument(doc.id)} + /> +
+
{doc.name}{doc.description}{doc.size}{new Date(doc.update_time).toLocaleDateString()} +
+ +
+
+
+ ); +}; + +export default DocumentList; diff --git a/src/pages/KnowledgeBase/Detail/components/FileUploadModal.jsx b/src/pages/KnowledgeBase/Detail/components/FileUploadModal.jsx new file mode 100644 index 0000000..915f85e --- /dev/null +++ b/src/pages/KnowledgeBase/Detail/components/FileUploadModal.jsx @@ -0,0 +1,157 @@ +import React, { useRef, useEffect } from 'react'; + +/** + * 文件上传模态框组件 + */ +const FileUploadModal = ({ + show, + newFile, + fileErrors, + isSubmitting, + onClose, + onDescriptionChange, + onFileChange, + onFileDrop, + onDragOver, + onUploadAreaClick, + onUpload, +}) => { + const fileInputRef = useRef(null); + const modalRef = useRef(null); + + // 处理上传区域点击事件 + const handleUploadAreaClick = () => { + fileInputRef.current?.click(); + }; + + // 处理拖拽事件 + const handleDragOver = (e) => { + e.preventDefault(); + e.stopPropagation(); + onDragOver?.(e); + }; + + const handleDrop = (e) => { + e.preventDefault(); + e.stopPropagation(); + onFileDrop?.(e); + }; + + // 清理函数 + useEffect(() => { + return () => { + // 确保在组件卸载时清理所有引用 + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + if (modalRef.current) { + modalRef.current = null; + } + }; + }, []); + + if (!show) return null; + + return ( +
+
+
+
上传文件
+ +
+
+
+ + {newFile.file ? ( +
+

已选择文件:

+

{newFile.file.name}

+
+ ) : ( +
+

点击或拖拽文件到此处上传

+

支持 PDF, DOCX, TXT, CSV 等格式

+
+ )} + {fileErrors.file &&
{fileErrors.file}
} +
+
+ + +
+
+
+ + +
+
+
+ ); +}; + +export default FileUploadModal; diff --git a/src/pages/KnowledgeBase/Detail/components/KnowledgeBaseForm.jsx b/src/pages/KnowledgeBase/Detail/components/KnowledgeBaseForm.jsx new file mode 100644 index 0000000..f53e54f --- /dev/null +++ b/src/pages/KnowledgeBase/Detail/components/KnowledgeBaseForm.jsx @@ -0,0 +1,279 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; + +/** + * 知识库表单组件 + */ +const KnowledgeBaseForm = ({ + formData, + formErrors, + isSubmitting, + onInputChange, + onSubmit, + onDelete, + onTypeChange, + isAdmin, + departments, + availableGroups, +}) => { + // 获取当前用户信息 + const currentUser = useSelector((state) => state.auth.user); + + // 根据用户角色确定可以设置的知识库类型 + const isLeader = currentUser?.role === 'leader'; + + // 获取当前用户可以设置的知识库类型 + const getAvailableTypes = () => { + if (isAdmin) { + return [ + { value: 'admin', label: '公共知识库' }, + { value: 'leader', label: 'Leader 级知识库' }, + { value: 'member', label: 'Member 级知识库' }, + { value: 'private', label: '私有知识库' }, + { value: 'secret', label: '保密知识库' }, + ]; + } else if (isLeader) { + return [ + { value: 'admin', label: '公共知识库' }, + { value: 'member', label: 'Member 级知识库' }, + { value: 'private', label: '私有知识库' }, + ]; + } else { + return [ + { value: 'admin', label: '公共知识库' }, + { value: 'private', label: '私有知识库' }, + ]; + } + }; + + const availableTypes = getAvailableTypes(); + + // 检查类型是否被更改 + const hasTypeChanged = formData.original_type && formData.original_type !== formData.type; + + // 检查部门或组别是否被更改 + const hasDepartmentOrGroupChanged = + (formData.original_department && formData.department !== formData.original_department) || + (formData.original_group && formData.group !== formData.original_group); + + // 是否显示类型更改按钮 + const showTypeChangeButton = hasTypeChanged || (isAdmin && hasDepartmentOrGroupChanged); + + return ( +
+
+
知识库设置
+ +
+
+ + + {formErrors.name &&
{formErrors.name}
} +
+ +
+ + + {formErrors.desc &&
{formErrors.desc}
} +
+ +
+ +
+ {availableTypes.map((type) => ( +
+ + +
+ ))} +
+ {currentUser?.role === 'member' && ( + 您可以修改知识库类型为公共或私有。 + )} + {formErrors.type &&
{formErrors.type}
} +
+ + {/* 仅当不是私有知识库时才显示部门选项 */} + {formData.type !== 'private' && ( +
+ + {isAdmin ? ( + <> + + {formErrors.department && ( +
{formErrors.department}
+ )} + + ) : ( + <> + + 部门信息根据知识库创建者自动填写 + + )} +
+ )} + + {/* 仅当不是私有知识库时才显示组别选项 */} + {formData.type !== 'private' && ( +
+ + {isAdmin ? ( + <> + + {formErrors.group &&
{formErrors.group}
} + {!formData.department && ( + 请先选择部门 + )} + + ) : ( + <> + + 组别信息根据知识库创建者自动填写 + + )} +
+ )} + + {/* 类型更改按钮 */} + {showTypeChangeButton && ( +
+
+ {hasTypeChanged && ( +

+ 知识库类型已更改为 {formData.type} +

+ )} + {isAdmin && hasDepartmentOrGroupChanged &&

部门/组别已更改

} + 点击更新按钮单独保存这些更改 +
+ +
+ )} + +
+ + +
+
+
+
+ ); +}; + +export default KnowledgeBaseForm; diff --git a/src/pages/KnowledgeBase/Detail/components/UserPermissionsManager.jsx b/src/pages/KnowledgeBase/Detail/components/UserPermissionsManager.jsx new file mode 100644 index 0000000..afcc39b --- /dev/null +++ b/src/pages/KnowledgeBase/Detail/components/UserPermissionsManager.jsx @@ -0,0 +1,789 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import { showNotification } from '../../../../store/notification.slice'; +import SvgIcon from '../../../../components/SvgIcon'; + +/** + * 用户权限管理组件 + */ +const UserPermissionsManager = ({ knowledgeBase }) => { + const dispatch = useDispatch(); + + // State for pagination + const [currentPage, setCurrentPage] = useState(1); + const usersPerPage = 10; + + // State for edit modal + const [showEditModal, setShowEditModal] = useState(false); + const [editUser, setEditUser] = useState(null); + + // State for add user modal + const [showAddUserModal, setShowAddUserModal] = useState(false); + const [newUser, setNewUser] = useState({ + username: '', + email: '', + permissionType: '只读', + accessDuration: '一个月', + }); + + // State for batch operations + const [selectedUsers, setSelectedUsers] = useState([]); + const [selectAll, setSelectAll] = useState(false); + const [showBatchDropdown, setShowBatchDropdown] = useState(false); + const batchDropdownRef = useRef(null); + + // State for batch edit modal + const [showBatchEditModal, setShowBatchEditModal] = useState(false); + const [batchEditData, setBatchEditData] = useState({ + permissionType: '只读', + accessDuration: '一个月', + }); + + // Form errors + const [formErrors, setFormErrors] = useState({}); + + // Mock data for users with permissions + const [users, setUsers] = useState([ + { + id: '1001', + username: '张三', + email: 'zhang@abc.com', + permissionType: '只读', + accessDuration: '一个月', + }, + { + id: '1002', + username: '李四', + email: 'li@abc.com', + permissionType: '完全访问', + accessDuration: '永久', + }, + { + id: '1003', + username: '王五', + email: 'wang@abc.com', + permissionType: '只读', + accessDuration: '三个月', + }, + { + id: '1004', + username: '赵六', + email: 'zhao@abc.com', + permissionType: '完全访问', + accessDuration: '六个月', + }, + { + id: '1005', + username: '钱七', + email: 'qian@abc.com', + permissionType: '只读', + accessDuration: '一周', + }, + { + id: '1006', + username: '孙八', + email: 'sun@abc.com', + permissionType: '只读', + accessDuration: '一个月', + }, + { + id: '1007', + username: '周九', + email: 'zhou@abc.com', + permissionType: '完全访问', + accessDuration: '永久', + }, + { + id: '1008', + username: '吴十', + email: 'wu@abc.com', + permissionType: '只读', + accessDuration: '三个月', + }, + ]); + + // Get current users for pagination + const indexOfLastUser = currentPage * usersPerPage; + const indexOfFirstUser = indexOfLastUser - usersPerPage; + const currentUsers = users.slice(indexOfFirstUser, indexOfLastUser); + const totalPages = Math.ceil(users.length / usersPerPage); + + // Handle click outside batch dropdown + useEffect(() => { + function handleClickOutside(event) { + if (batchDropdownRef.current && !batchDropdownRef.current.contains(event.target)) { + setShowBatchDropdown(false); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + // Handle edit user permissions + const handleEditUser = (user) => { + setEditUser({ ...user }); + setFormErrors({}); + setShowEditModal(true); + }; + + // Handle input change in edit modal + const handleEditInputChange = (e) => { + const { name, value } = e.target; + setEditUser((prev) => ({ + ...prev, + [name]: value, + })); + + // Clear error if exists + if (formErrors[name]) { + setFormErrors((prev) => ({ + ...prev, + [name]: '', + })); + } + }; + + // Handle input change in add user modal + const handleAddUserInputChange = (e) => { + const { name, value } = e.target; + setNewUser((prev) => ({ + ...prev, + [name]: value, + })); + + // Clear error if exists + if (formErrors[name]) { + setFormErrors((prev) => ({ + ...prev, + [name]: '', + })); + } + }; + + // Handle input change in batch edit modal + const handleBatchEditInputChange = (e) => { + const { name, value } = e.target; + setBatchEditData((prev) => ({ + ...prev, + [name]: value, + })); + }; + + // Validate edit form + const validateEditForm = () => { + const errors = {}; + + if (!editUser.username.trim()) { + errors.username = '请输入用户名'; + } + + if (!editUser.email.trim()) { + errors.email = '请输入邮箱'; + } else if (!/\S+@\S+\.\S+/.test(editUser.email)) { + errors.email = '请输入有效的邮箱地址'; + } + + setFormErrors(errors); + return Object.keys(errors).length === 0; + }; + + // Validate add user form + const validateAddUserForm = () => { + const errors = {}; + + if (!newUser.username.trim()) { + errors.username = '请输入用户名'; + } + + if (!newUser.email.trim()) { + errors.email = '请输入邮箱'; + } else if (!/\S+@\S+\.\S+/.test(newUser.email)) { + errors.email = '请输入有效的邮箱地址'; + } + + setFormErrors(errors); + return Object.keys(errors).length === 0; + }; + + // Handle save user permissions + const handleSaveUserPermissions = () => { + // Validate form + if (!validateEditForm()) { + return; + } + + // Here you would typically call an API to update the user permissions + console.log('Updating user permissions:', editUser); + + // Update the users array with the edited user + setUsers((prevUsers) => prevUsers.map((user) => (user.id === editUser.id ? { ...editUser } : user))); + + // Show success notification + dispatch(showNotification({ message: '用户权限已更新', type: 'success' })); + + // Close modal + setShowEditModal(false); + }; + + // Handle add new user + const handleAddUser = () => { + // Validate form + if (!validateAddUserForm()) { + return; + } + + // Generate a new ID + const newId = `user-${Date.now()}`; + + // Create new user object + const userToAdd = { + id: newId, + username: newUser.username, + email: newUser.email, + permissionType: newUser.permissionType, + accessDuration: newUser.accessDuration, + }; + + // Add new user to the users array + setUsers((prevUsers) => [...prevUsers, userToAdd]); + + // Show success notification + dispatch(showNotification({ message: '用户已添加', type: 'success' })); + + // Reset form and close modal + setNewUser({ + username: '', + email: '', + permissionType: '只读', + accessDuration: '一个月', + }); + setShowAddUserModal(false); + }; + + // Handle delete user + const handleDeleteUser = (userId) => { + // Here you would typically call an API to delete the user + console.log('Deleting user:', userId); + + // Remove user from the users array + setUsers((prevUsers) => prevUsers.filter((user) => user.id !== userId)); + + // Remove from selected users if present + setSelectedUsers((prev) => prev.filter((id) => id !== userId)); + + // Show success notification + dispatch(showNotification({ message: '用户已删除', type: 'success' })); + }; + + // Handle batch delete + const handleBatchDelete = () => { + if (selectedUsers.length === 0) return; + + // Here you would typically call an API to delete the selected users + console.log('Batch deleting users:', selectedUsers); + + // Remove selected users from the users array + setUsers((prevUsers) => prevUsers.filter((user) => !selectedUsers.includes(user.id))); + + // Reset selection + setSelectedUsers([]); + setSelectAll(false); + setShowBatchDropdown(false); + + // Show success notification + dispatch(showNotification({ message: `已删除 ${selectedUsers.length} 个用户`, type: 'success' })); + }; + + // Handle batch edit + const handleBatchEdit = () => { + if (selectedUsers.length === 0) return; + + // Here you would typically call an API to update the selected users + console.log('Batch editing users:', selectedUsers, 'with data:', batchEditData); + + // Update selected users in the users array + setUsers((prevUsers) => + prevUsers.map((user) => (selectedUsers.includes(user.id) ? { ...user, ...batchEditData } : user)) + ); + + // Close modal + setShowBatchEditModal(false); + setShowBatchDropdown(false); + + // Show success notification + dispatch(showNotification({ message: `已更新 ${selectedUsers.length} 个用户的权限`, type: 'success' })); + }; + + // Handle select all checkbox + const handleSelectAll = () => { + if (selectAll) { + setSelectedUsers([]); + } else { + setSelectedUsers(currentUsers.map((user) => user.id)); + } + setSelectAll(!selectAll); + }; + + // Handle individual user selection + const handleSelectUser = (userId) => { + if (selectedUsers.includes(userId)) { + setSelectedUsers(selectedUsers.filter((id) => id !== userId)); + setSelectAll(false); + } else { + setSelectedUsers([...selectedUsers, userId]); + // Check if all current page users are now selected + if (selectedUsers.length + 1 === currentUsers.length) { + setSelectAll(true); + } + } + }; + + // Handle page change + const handlePageChange = (pageNumber) => { + setCurrentPage(pageNumber); + // Reset selection when changing pages + setSelectedUsers([]); + setSelectAll(false); + }; + + return ( +
+
+
+
用户权限管理
+
+ {selectedUsers.length > 0 && ( +
+ + {showBatchDropdown && ( +
    +
  • + +
  • +
  • + +
  • +
+ )} +
+ )} + +
+
+ +
+ + + + + + + + + + + + + {currentUsers.length > 0 ? ( + currentUsers.map((user) => ( + + + + + + + + + )) + ) : ( + + + + )} + +
+
+ +
+
用户名邮箱权限类型访问期限操作
+
+ handleSelectUser(user.id)} + /> +
+
{user.username}{user.email} + + {user.permissionType} + + {user.accessDuration} +
+ + +
+
+ 暂无用户 +
+
+ + {/* Pagination */} + {totalPages > 1 && ( + + )} + + {/* Edit User Modal */} + {showEditModal && ( +
+
+
+
+
编辑用户权限
+ +
+
+
+
+ + + {formErrors.username && ( +
{formErrors.username}
+ )} +
+
+ + + {formErrors.email && ( +
{formErrors.email}
+ )} +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ )} + + {/* Add User Modal */} + {showAddUserModal && ( +
+
+
+
+
添加用户
+ +
+
+
+
+ + + {formErrors.username && ( +
{formErrors.username}
+ )} +
+
+ + + {formErrors.email && ( +
{formErrors.email}
+ )} +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ )} + + {/* Batch Edit Modal */} + {showBatchEditModal && ( +
+
+
+
+
批量修改权限
+ +
+
+

+ 您正在修改 {selectedUsers.length} 个用户的权限 +

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ )} + + {/* Modal backdrop */} + {(showEditModal || showAddUserModal || showBatchEditModal) && ( +
+ )} +
+
+ ); +}; + +export default UserPermissionsManager; diff --git a/src/pages/KnowledgeBase/KnowledgeBase.css b/src/pages/KnowledgeBase/KnowledgeBase.css new file mode 100644 index 0000000..785612f --- /dev/null +++ b/src/pages/KnowledgeBase/KnowledgeBase.css @@ -0,0 +1,53 @@ +.knowledge-base-page { + max-width: 1200px; + margin: 0 auto; + padding: 0 20px; +} + +.knowledge-base-header { + background-color: #f8f9fa; + border-radius: 10px; + margin-bottom: 20px; +} + +.knowledge-base-cards-container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; + margin-top: 20px; +} + +.search-bar-container { + position: relative; + flex: 1; + max-width: 500px; +} + +.search-results-dropdown { + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); + border-radius: 8px; + z-index: 1050; +} + +.search-result-item { + transition: background-color 0.2s; +} + +.search-result-item:hover { + background-color: #f8f9fa; +} + +.hover-bg-light:hover { + background-color: #f8f9fa; +} + +/* 响应式样式 */ +@media (max-width: 768px) { + .knowledge-base-cards-container { + grid-template-columns: 1fr; + } + + .search-bar-container { + max-width: 100%; + } +} \ No newline at end of file diff --git a/src/pages/KnowledgeBase/KnowledgeBase.jsx b/src/pages/KnowledgeBase/KnowledgeBase.jsx index 2db1539..4240de1 100644 --- a/src/pages/KnowledgeBase/KnowledgeBase.jsx +++ b/src/pages/KnowledgeBase/KnowledgeBase.jsx @@ -1,48 +1,535 @@ -import React from 'react'; -import KnowledgeCard from './KnowledgeCard'; -import { useDispatch } from 'react-redux'; +import React, { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; +import { showNotification } from '../../store/notification.slice'; +import { + fetchKnowledgeBases, + searchKnowledgeBases, + createKnowledgeBase, + deleteKnowledgeBase, + requestKnowledgeBaseAccess, +} from '../../store/knowledgeBase/knowledgeBase.thunks'; +import { clearSearchResults } from '../../store/knowledgeBase/knowledgeBase.slice'; +import SvgIcon from '../../components/SvgIcon'; +import AccessRequestModal from '../../components/AccessRequestModal'; +import CreateKnowledgeBaseModal from '../../components/CreateKnowledgeBaseModal'; +import Pagination from '../../components/Pagination'; +import SearchBar from '../../components/SearchBar'; +import ApiModeSwitch from '../../components/ApiModeSwitch'; + +// 导入拆分的组件 +import KnowledgeBaseList from './components/KnowledgeBaseList'; export default function KnowledgeBase() { const dispatch = useDispatch(); + const navigate = useNavigate(); + const [showCreateModal, setShowCreateModal] = useState(false); + const [showAccessRequestModal, setShowAccessRequestModal] = useState(false); + const [formErrors, setFormErrors] = useState({}); + const [accessRequestKnowledgeBase, setAccessRequestKnowledgeBase] = useState({ + id: '', + title: '', + }); + const [isSubmittingRequest, setIsSubmittingRequest] = useState(false); + const [createdKnowledgeBaseId, setCreatedKnowledgeBaseId] = useState(null); - const knowledgeList = [ - { - title: '产品开发知识库', - description: '产品开发流程及规范说明文档', - documents: 24, - date: '2025-02-15', - access: 'full', - }, - { - title: '市场分析知识库', - description: '2025年Q1市场分析总结', - documents: 12, - date: '2025-02-10', - access: 'read', - }, - { title: '财务知识库', description: '月度财务分析报告', documents: 8, date: '2025-02-01', access: 'none' }, - ]; + // 获取当前用户信息 + const currentUser = useSelector((state) => state.auth.user); + + const [newKnowledgeBase, setNewKnowledgeBase] = useState({ + name: '', + desc: '', + type: 'private', // 默认为私有知识库 + department: currentUser?.department || '', + group: currentUser?.group || '', + }); + + // Search state + const [searchKeyword, setSearchKeyword] = useState(''); + const [isSearchDropdownOpen, setIsSearchDropdownOpen] = useState(false); + + // Pagination state + const [pagination, setPagination] = useState({ + page: 1, + page_size: 10, + }); + + // Get knowledge bases from Redux store + // 更新为新的Redux状态结构 + const knowledgeBases = useSelector((state) => state.knowledgeBase.knowledgeBases); + const loading = useSelector((state) => state.knowledgeBase.loading); + const paginationData = useSelector((state) => state.knowledgeBase.pagination); + const error = useSelector((state) => state.knowledgeBase.error); + const operationStatus = useSelector((state) => state.knowledgeBase.editStatus); + const operationError = useSelector((state) => state.knowledgeBase.error); + + // 从Redux获取搜索结果和加载状态 + const searchResults = useSelector((state) => state.knowledgeBase.searchResults); + const searchLoading = useSelector((state) => state.knowledgeBase.searchLoading); + + // Fetch knowledge bases when component mounts or pagination changes + useEffect(() => { + // 无论是否在搜索,都正常获取知识库列表 + dispatch(fetchKnowledgeBases(pagination)); + }, [dispatch, pagination.page, pagination.page_size]); + + // Show loading state while fetching data + const isLoading = loading; + + // Show error notification if fetch fails + useEffect(() => { + if (!isLoading && error) { + dispatch( + showNotification({ + message: `获取知识库列表失败: ${error.message || error}`, + type: 'danger', + }) + ); + } + }, [isLoading, error, dispatch]); + + // Show notification for operation status + useEffect(() => { + if (operationStatus === 'successful') { + // 操作成功通知由具体函数处理,这里只刷新列表 + + // Refresh the list after successful operation + dispatch(fetchKnowledgeBases(pagination)); + } else if (operationStatus === 'failed' && operationError) { + dispatch( + showNotification({ + message: `操作失败: ${operationError.message || operationError}`, + type: 'danger', + }) + ); + } + }, [operationStatus, operationError, dispatch, pagination]); + + // Handle search input change + const handleSearchInputChange = (e) => { + const value = e.target.value; + setSearchKeyword(value); + + // 如果搜索框清空,关闭下拉框 + if (!value.trim()) { + dispatch(clearSearchResults()); + setIsSearchDropdownOpen(false); + } + }; + + // Handle search submit - 只影响下拉框,不影响主列表 + const handleSearch = (e) => { + e.preventDefault(); + + if (searchKeyword.trim()) { + // 只设置下拉框搜索状态,不设置全局isSearching状态 + dispatch( + searchKnowledgeBases({ + keyword: searchKeyword, + page: 1, + page_size: 5, // 下拉框只显示少量结果 + }) + ); + setIsSearchDropdownOpen(true); + } else { + // 清空搜索及关闭下拉框 + handleClearSearch(); + } + }; + + // Handle clear search + const handleClearSearch = () => { + setSearchKeyword(''); + // 不影响主列表显示,只关闭下拉框 + setIsSearchDropdownOpen(false); + dispatch(clearSearchResults()); + }; + + // Handle pagination change + const handlePageChange = (newPage) => { + setPagination((prev) => ({ + ...prev, + page: newPage, + })); + }; + + // Handle page size change + const handlePageSizeChange = (newPageSize) => { + setPagination({ + page: 1, // Reset to first page when changing page size + page_size: newPageSize, + }); + }; + + const handleInputChange = (e) => { + const { name, value } = e.target; + const isAdmin = currentUser?.role === 'admin'; + const isLeader = currentUser?.role === 'leader'; + + // 根据用户角色允许修改部门和组别字段 + if (name === 'department' || name === 'group') { + // 仅管理员可以修改部门 + if (name === 'department' && !isAdmin) { + return; + } + + // 仅管理员和组长可以修改组别 + if (name === 'group' && !isAdmin && !isLeader) { + return; + } + + // 更新字段值 + setNewKnowledgeBase((prev) => ({ + ...prev, + [name]: value, + })); + + // 如果更新部门,重置组别 + if (name === 'department') { + setNewKnowledgeBase((prev) => ({ + ...prev, + group: '', // 部门变更时重置组别 + })); + } + + return; + } + + // 检查用户是否有权限选择指定的知识库类型 + if (name === 'type') { + const role = currentUser?.role; + let allowed = false; + + // 根据角色判断可以选择的知识库类型 + if (role === 'admin') { + // 管理员可以选择任何类型 + allowed = ['admin', 'leader', 'member', 'private', 'secret'].includes(value); + } else if (role === 'leader') { + // 组长只能选择 member 和 private + allowed = ['member', 'private'].includes(value); + } else { + // 普通成员只能选择 private + allowed = value === 'private'; + } + + if (!allowed) { + dispatch( + showNotification({ + message: '您没有权限创建此类型的知识库', + type: 'warning', + }) + ); + return; + } + } + + setNewKnowledgeBase((prev) => ({ + ...prev, + [name]: value, + })); + + // Clear error when user types + if (formErrors[name]) { + setFormErrors((prev) => ({ + ...prev, + [name]: '', + })); + } + }; + + const validateCreateForm = () => { + const errors = {}; + const isAdmin = currentUser?.role === 'admin'; + const isLeader = currentUser?.role === 'leader'; + // 只有member类型知识库需要选择组别,私有知识库不需要 + const needSelectGroup = newKnowledgeBase.type === 'member'; + // 私有知识库不需要选择部门和组别 + const isPrivate = newKnowledgeBase.type === 'private'; + + if (!newKnowledgeBase.name.trim()) { + errors.name = '请输入知识库名称'; + } + + if (!newKnowledgeBase.desc.trim()) { + errors.desc = '请输入知识库描述'; + } + + if (!newKnowledgeBase.type) { + errors.type = '请选择知识库类型'; + } + + // 对于member级别的知识库,检查是否选择了部门和组别 + if (needSelectGroup && !isPrivate) { + // 管理员必须选择部门 + if (isAdmin && !newKnowledgeBase.department) { + errors.department = '创建member级别知识库时必须选择部门'; + } + + // 所有用户创建member级别知识库时必须选择组别 + if (!newKnowledgeBase.group) { + errors.group = '创建member级别知识库时必须选择组别'; + } + } + + setFormErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleCreateKnowledgeBase = async () => { + // Validate form + if (!validateCreateForm()) { + return; + } + + try { + // 私有知识库不需要部门和组别信息 + const isPrivate = newKnowledgeBase.type === 'private'; + + // Dispatch create knowledge base action + const resultAction = await dispatch( + createKnowledgeBase({ + name: newKnowledgeBase.name, + desc: newKnowledgeBase.desc, + description: newKnowledgeBase.desc, + type: newKnowledgeBase.type, + department: !isPrivate ? newKnowledgeBase.department : '', + group: !isPrivate ? newKnowledgeBase.group : '', + }) + ); + + console.log('创建知识库返回数据:', resultAction); + + // Check if the action was successful + if (createKnowledgeBase.fulfilled.match(resultAction)) { + console.log('创建成功,payload:', resultAction.payload); + const { knowledge_base } = resultAction.payload; + const { id } = knowledge_base; + // Get ID from payload and navigate + if (id) { + console.log('新知识库ID:', id); + + // 显示成功通知 + dispatch( + showNotification({ + message: '知识库创建成功', + type: 'success', + }) + ); + + // 直接导航到新创建的知识库详情页 + navigate(`/knowledge-base/${id}`); + } else { + console.error('无法获取新知识库ID:', resultAction.payload); + dispatch( + showNotification({ + message: '创建成功,但无法获取知识库ID', + type: 'warning', + }) + ); + } + } else { + console.error('创建知识库失败:', resultAction.error); + dispatch( + showNotification({ + message: `创建知识库失败: ${resultAction.error?.message || '未知错误'}`, + type: 'danger', + }) + ); + } + } catch (error) { + console.error('创建知识库出错:', error); + dispatch( + showNotification({ + message: `创建知识库出错: ${error.message || '未知错误'}`, + type: 'danger', + }) + ); + } + + // Reset form and close modal + setNewKnowledgeBase({ name: '', desc: '', type: 'private', department: '', group: '' }); + setFormErrors({}); + setShowCreateModal(false); + }; + + // Handle card click to navigate to knowledge base detail + const handleCardClick = (id, permissions) => { + // 检查用户是否有读取权限 + if (!permissions || permissions.can_read === false) { + dispatch( + showNotification({ + message: '您没有访问此知识库的权限,请先申请权限', + type: 'warning', + }) + ); + return; + } + + // 有权限则跳转到详情页 + navigate(`/knowledge-base/${id}/datasets`); + }; + + const handleRequestAccess = (id, title) => { + setAccessRequestKnowledgeBase({ + id, + title, + }); + setShowAccessRequestModal(true); + }; + + const handleSubmitAccessRequest = async (requestData) => { + setIsSubmittingRequest(true); + + try { + // 使用权限服务发送请求 - 通过dispatch调用thunk + await dispatch(requestKnowledgeBaseAccess(requestData)).unwrap(); + + // Close modal after success + setShowAccessRequestModal(false); + } catch (error) { + dispatch( + showNotification({ + message: `权限申请失败: ${error.response?.data?.message || '请稍后重试'}`, + type: 'danger', + }) + ); + } finally { + setIsSubmittingRequest(false); + } + }; + + const handleDelete = (e, id) => { + e.preventDefault(); + e.stopPropagation(); + // Dispatch delete knowledge base action + dispatch(deleteKnowledgeBase(id)) + .unwrap() + .then(() => { + dispatch( + showNotification({ + message: '知识库已删除', + type: 'success', + }) + ); + }) + .catch((error) => { + dispatch( + showNotification({ + message: `删除失败: ${error.message || '未知错误'}`, + type: 'danger', + }) + ); + }); + }; + + // Calculate total pages + const totalPages = Math.ceil(paginationData.total / pagination.page_size); + + // 打开创建知识库弹窗 + const handleOpenCreateModal = () => { + const isAdmin = currentUser?.role === 'admin'; + const isLeader = currentUser?.role === 'leader'; + + // 默认知识库类型设为公共知识库 + let defaultType = 'admin'; + + // 初始部门和组别 + let department = currentUser?.department || ''; + let group = currentUser?.group || ''; + + setNewKnowledgeBase({ + name: '', + desc: '', + type: defaultType, + department: department, + group: group, + }); + + setFormErrors({}); + setShowCreateModal(true); + }; + + // 处理点击搜索结果 + const handleSearchResultClick = (id, permissions) => { + if (permissions?.can_read) { + navigate(`/knowledge-base/${id}/datasets`); + } + }; return ( -
+
+ {/*
+ +
*/}
- -
-
- {knowledgeList.map((item, index) => ( - - ))} -
+ {isLoading ? ( +
+
+ 加载中... +
+
+ ) : ( + <> + + + {/* Pagination - 始终显示 */} + {totalPages > 1 && ( + + )} + + )} + + {/* 新建知识库弹窗 */} + setShowCreateModal(false)} + onChange={handleInputChange} + onSubmit={handleCreateKnowledgeBase} + currentUser={currentUser} + /> + + {/* 申请权限弹窗 */} + setShowAccessRequestModal(false)} + onSubmit={handleSubmitAccessRequest} + isSubmitting={isSubmittingRequest} + />
); } diff --git a/src/pages/KnowledgeBase/KnowledgeCard.jsx b/src/pages/KnowledgeBase/KnowledgeCard.jsx deleted file mode 100644 index b14cd6a..0000000 --- a/src/pages/KnowledgeBase/KnowledgeCard.jsx +++ /dev/null @@ -1,132 +0,0 @@ -import React from 'react'; - -export default function KnowledgeCard({ title, description, documents, date, access }) { - return ( -
-
-
-
{title}
-
- -
    -
  • - 删除 - - - - -
  • -
-
-

{description}

-
- - - - {documents} 文档 - - - - - {date} - -
-
- {access === 'full' ? ( - - - - - 完全访问 - - ) : access === 'read' ? ( - - - - - 只读访问 - - ) : ( - - - - - 无访问权限 - - )} - {access === 'full' || access === 'read' ? ( - - ) : ( - - )} -
-
-
-
- ); -} diff --git a/src/pages/KnowledgeBase/components/KnowledgeBaseList.jsx b/src/pages/KnowledgeBase/components/KnowledgeBaseList.jsx new file mode 100644 index 0000000..4b51caf --- /dev/null +++ b/src/pages/KnowledgeBase/components/KnowledgeBaseList.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import KnowledgeCard from './KnowledgeCard'; + +/** + * 知识库列表组件 + */ +const KnowledgeBaseList = ({ knowledgeBases, isSearching, onCardClick, onRequestAccess, onDelete }) => { + if (!knowledgeBases?.length) { + return ( +
+ {isSearching ? '没有找到匹配的知识库' : '暂无知识库,请创建新的知识库'} +
+ ); + } + + return ( +
+ {knowledgeBases.map((item) => ( + + onCardClick(item.id, item.permissions)} + onRequestAccess={onRequestAccess} + onDelete={(e) => onDelete(e, item.id)} + type={item.type} + department={item.department} + group={item.group} + /> + + ))} +
+ ); +}; + +export default KnowledgeBaseList; diff --git a/src/pages/KnowledgeBase/components/KnowledgeCard.jsx b/src/pages/KnowledgeBase/components/KnowledgeCard.jsx new file mode 100644 index 0000000..022f478 --- /dev/null +++ b/src/pages/KnowledgeBase/components/KnowledgeCard.jsx @@ -0,0 +1,117 @@ +import React from 'react'; +import SvgIcon from '../../../components/SvgIcon'; +import { useNavigate } from 'react-router-dom'; + +export default function KnowledgeCard({ + id, + title, + description, + documents, + date, + access, + permissions, + onClick, + onRequestAccess, + onDelete, + type, + department, + group, +}) { + const navigate = useNavigate(); + + const handleNewChat = (e) => { + e.preventDefault(); + e.stopPropagation(); + navigate(`/chat/${id}`); + }; + + const handleRequestAccess = (e) => { + e.preventDefault(); + e.stopPropagation(); + onRequestAccess(id, title); + }; + + // 自定义样式,限制文本最多显示两行 + const descriptionStyle = { + display: '-webkit-box', + WebkitLineClamp: 2, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + textOverflow: 'ellipsis', + minHeight: '3rem', // 保持一致的高度,即使描述很短 + }; + + return ( +
+
+
+ {permissions && permissions.can_delete && ( +
+ +
    +
  • + 删除 + +
  • +
+
+ )} +

+ {description} +

+
+ + {documents} 文档 + + + {date} + +
+ {/*
+ + {type === 'private' ? '私有' : '公开'} + + {department && {department}} + {group && {group}} +
*/} +
+ {access === 'full' ? ( + + + 完全访问 + + ) : access === 'read' ? ( + + + 只读访问 + + ) : ( + + + 无访问权限 + + )} + {access === 'full' || access === 'read' ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/src/pages/Permissions/Permissions.css b/src/pages/Permissions/Permissions.css new file mode 100644 index 0000000..5fa7f2d --- /dev/null +++ b/src/pages/Permissions/Permissions.css @@ -0,0 +1,39 @@ +.permissions-container { + padding: 24px; + background-color: #f8f9fa; + min-height: calc(100vh - 64px); +} + +.permissions-section { + background-color: #fff; + border-radius: 8px; + padding: 24px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); +} + +.api-mode-control { + background-color: #fff; + border-radius: 8px; + padding: 16px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); +} + +.api-mode-control .api-mode-switch { + display: flex; + flex-direction: column; +} + +@media (max-width: 768px) { + .permissions-container { + padding: 16px; + } + + .permissions-section, + .api-mode-control { + padding: 16px; + } +} + +.permissions-section:last-child { + margin-bottom: 0; +} \ No newline at end of file diff --git a/src/pages/Permissions/PermissionsPage.jsx b/src/pages/Permissions/PermissionsPage.jsx new file mode 100644 index 0000000..afb8466 --- /dev/null +++ b/src/pages/Permissions/PermissionsPage.jsx @@ -0,0 +1,31 @@ +import React, { useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; +import PendingRequests from './components/PendingRequests'; +import UserPermissions from './components/UserPermissions'; +import './Permissions.css'; + +export default function PermissionsPage() { + const navigate = useNavigate(); + const { user } = useSelector((state) => state.auth); + + // 检查用户是否有管理权限(leader 或 admin) + useEffect(() => { + if (!user || (user.role !== 'leader' && user.role !== 'admin')) { + navigate('/'); + } + }, [user, navigate]); + + return ( +
+
+ +
+ {user && user.role === 'admin' && ( +
+ +
+ )} +
+ ); +} diff --git a/src/pages/Permissions/components/PendingRequests.css b/src/pages/Permissions/components/PendingRequests.css new file mode 100644 index 0000000..9ddbd81 --- /dev/null +++ b/src/pages/Permissions/components/PendingRequests.css @@ -0,0 +1,251 @@ +.permission-requests { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.permission-card { + transition: all 0.2s ease; + border-radius: 8px; + background-color: #343a40; +} + +.permission-card:hover { + transform: translateY(-2px); + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important; +} + +.custom-badge { + padding: 0.35em 0.65em; + font-size: 0.85em; +} + +.badge.bg-info { + background-color: #0dcaf0 !important; +} + +.badge.bg-success { + background-color: #198754 !important; +} + +.badge.bg-danger { + background-color: #dc3545 !important; +} + +.badge.bg-secondary { + background-color: #6c757d !important; +} + +/* 表格行鼠标样式 */ +.cursor-pointer { + cursor: pointer; +} + +/* 滑动面板样式 */ +.slide-over-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1040; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease, visibility 0.3s ease; +} + +.slide-over-backdrop.show { + opacity: 1; + visibility: visible; +} + +.slide-over { + position: fixed; + top: 0; + right: -450px; + width: 450px; + height: 100%; + background-color: #fff; + z-index: 1050; + transition: right 0.3s ease; + box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1); +} + +.slide-over.show { + right: 0; +} + +.slide-over-content { + display: flex; + flex-direction: column; + height: 100%; +} + +.slide-over-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-bottom: 1px solid #e9ecef; +} + +.slide-over-body { + flex: 1; + padding: 1rem; + overflow-y: auto; +} + +.slide-over-footer { + padding: 1rem; + border-top: 1px solid #e9ecef; + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + +/* 头像占位符 */ +.avatar-placeholder { + width: 40px; + height: 40px; + border-radius: 50%; + background-color: #6c757d; + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: 1.2rem; +} + +/* 新增样式 - 白色基调 */ +.badge-count { + background-color: #ff4d4f; + color: white; + border-radius: 20px; + padding: 4px 12px; + font-size: 14px; +} + +.pending-requests-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.pending-request-item { + position: relative; + background-color: #fff; + border-radius: 8px; + padding: 16px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + cursor: pointer; + transition: all 0.2s ease; +} + +.pending-request-item:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.request-header { + display: flex; + justify-content: space-between; + margin-bottom: 12px; +} + +.user-info h6 { + font-weight: 600; +} + +.department { + color: #666; + font-size: 14px; + margin: 0; +} + +.request-date { + color: #999; + font-size: 14px; +} + +.request-content { + color: #333; +} + +.permission-badges { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.permission-badge { + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; +} + +.permission-badge.read { + background-color: #e6f7ff; + color: #1890ff; + border: 1px solid #91d5ff; +} + +.permission-badge.edit { + background-color: #f6ffed; + color: #52c41a; + border: 1px solid #b7eb8f; +} + +.permission-badge.delete { + background-color: #fff2f0; + color: #ff4d4f; + border: 1px solid #ffccc7; +} + +.request-actions { + position: absolute; + right: 1rem; + bottom: 1rem; + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; +} + + +/* 分页控件样式 */ +.pagination-container { + display: flex; + justify-content: center; + align-items: center; + margin-top: 20px; + padding: 10px 0; +} + +.pagination-button { + background-color: #fff; + border: 1px solid #d9d9d9; + color: #333; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; +} + +.pagination-button:hover:not(:disabled) { + border-color: #1890ff; + color: #1890ff; +} + +.pagination-button:disabled { + color: #d9d9d9; + cursor: not-allowed; +} + +.pagination-info { + margin: 0 15px; + color: #666; + font-size: 14px; +} \ No newline at end of file diff --git a/src/pages/Permissions/components/PendingRequests.jsx b/src/pages/Permissions/components/PendingRequests.jsx new file mode 100644 index 0000000..09b47e4 --- /dev/null +++ b/src/pages/Permissions/components/PendingRequests.jsx @@ -0,0 +1,442 @@ +import React, { useState, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useLocation } from 'react-router-dom'; +import { showNotification } from '../../../store/notification.slice'; +import { + fetchPermissionsThunk, + approvePermissionThunk, + rejectPermissionThunk, +} from '../../../store/permissions/permissions.thunks'; +import { resetOperationStatus } from '../../../store/permissions/permissions.slice'; +import './PendingRequests.css'; // 引入外部CSS文件 +import SvgIcon from '../../../components/SvgIcon'; +import RequestDetailSlideOver from './RequestDetailSlideOver'; + +export default function PendingRequests() { + const dispatch = useDispatch(); + const location = useLocation(); + const [responseMessage, setResponseMessage] = useState(''); + const [showResponseInput, setShowResponseInput] = useState(false); + const [currentRequestId, setCurrentRequestId] = useState(null); + const [isApproving, setIsApproving] = useState(false); + const [selectedRequest, setSelectedRequest] = useState(null); + const [showSlideOver, setShowSlideOver] = useState(false); + + // 从Redux store获取权限申请列表状态 + const { + results: permissionRequests, + status: fetchStatus, + error: fetchError, + page: currentPage, + page_size: pageSize, + total, + } = useSelector((state) => state.permissions.pending); + + // 计算总页数 + const totalPages = Math.ceil(total / pageSize) || 1; + + // 从Redux store获取批准/拒绝操作的状态 + const { + status: approveRejectStatus, + error: approveRejectError, + currentId: processingId, + } = useSelector((state) => state.permissions.approveReject); + + // 获取待处理申请列表 + useEffect(() => { + dispatch(fetchPermissionsThunk({ page: currentPage, page_size: pageSize })); + }, [dispatch, currentPage, pageSize]); + + // 处理从通知中心跳转过来的请求 + useEffect(() => { + if (location.state?.showRequestDetail) { + const { requestId, requestData } = location.state; + setSelectedRequest(requestData); + setShowSlideOver(true); + // 清除 location state + window.history.replaceState({}, document.title); + } + }, [location.state]); + + // 监听批准/拒绝操作的状态变化 + useEffect(() => { + if (approveRejectStatus === 'succeeded') { + dispatch( + showNotification({ + message: isApproving ? '已批准申请' : '已拒绝申请', + type: 'success', + }) + ); + setShowResponseInput(false); + setCurrentRequestId(null); + setResponseMessage(''); + setShowSlideOver(false); + setSelectedRequest(null); + + // 重新获取申请列表 + dispatch(fetchPermissionsThunk({ page: currentPage, page_size: pageSize })); + + // 重置状态 + dispatch(resetOperationStatus()); + } else if (approveRejectStatus === 'failed') { + dispatch( + showNotification({ + message: approveRejectError || '处理申请失败', + type: 'danger', + }) + ); + // 重置状态 + dispatch(resetOperationStatus()); + } + }, [approveRejectStatus, approveRejectError, dispatch, isApproving, currentPage, pageSize]); + + // 打开回复输入框 + const handleOpenResponseInput = (requestId, approving) => { + setCurrentRequestId(requestId); + setIsApproving(approving); + setShowResponseInput(true); + }; + + // 关闭回复输入框 + const handleCloseResponseInput = () => { + setShowResponseInput(false); + setCurrentRequestId(null); + setResponseMessage(''); + }; + + // 处理申请(批准或拒绝) + const handleProcessRequest = () => { + if (!currentRequestId) return; + + const params = { + id: currentRequestId, + responseMessage, + }; + + if (isApproving) { + dispatch(approvePermissionThunk(params)); + } else { + dispatch(rejectPermissionThunk(params)); + } + }; + + // 处理行点击,显示滑动面板 + const handleRowClick = (request) => { + setSelectedRequest(request); + setShowSlideOver(true); + }; + + // 关闭滑动面板 + const handleCloseSlideOver = () => { + setShowSlideOver(false); + setTimeout(() => { + setSelectedRequest(null); + }, 300); // 等待动画结束后再清除选中的申请 + }; + + // 直接处理申请(不显示弹窗) + const handleDirectProcess = (requestId, approve) => { + setCurrentRequestId(requestId); + setIsApproving(approve); + + const params = { + id: requestId, + responseMessage: approve ? '已批准' : '已拒绝', + }; + + if (approve) { + dispatch(approvePermissionThunk(params)); + } else { + dispatch(rejectPermissionThunk(params)); + } + }; + + // 处理页码变化 + const handlePageChange = (page) => { + dispatch(fetchPermissionsThunk({ page, page_size: pageSize })); + }; + + // 渲染分页控件 + const renderPagination = () => { + if (totalPages <= 1) return null; + + return ( +
+ + +
+ {currentPage} / {totalPages} +
+ + +
+ ); + }; + + // 根据状态渲染不同的状态标签 + const renderStatusBadge = (status) => { + switch (status) { + case 'approved': + return ( + + + 已批准 + + ); + case 'rejected': + return ( + + + 已拒绝 + + ); + case 'pending': + return ( + + 待处理 + + ); + default: + return null; + } + }; + + // 渲染加载状态 + if (fetchStatus === 'loading' && permissionRequests.length === 0) { + return ( +
+
+ 加载中... +
+

加载权限申请...

+
+ ); + } + + // 渲染错误状态 + if (fetchStatus === 'failed' && permissionRequests.length === 0) { + return ( +
+ {fetchError || '获取权限申请失败'} +
+ ); + } + + // 渲染空状态 + if (permissionRequests.length === 0) { + return ( +
+ 暂无权限申请记录 +
+ ); + } + + // 渲染申请列表 + return ( + <> +
+
权限申请列表
+
+ {permissionRequests.filter((req) => req.status === 'pending').length}个待处理 +
+
+ +
+ {permissionRequests.map((request) => ( +
handleRowClick(request)}> +
+
+
{request.applicant.name || request.applicant.username}
+ {request.applicant.department || '未分配部门'} +
+
{new Date(request.created_at).toLocaleDateString()}
+
+ +
+
+

申请访问:{request.knowledge_base.name}

+
+ + {request.permissions.can_edit ? ( + + + 完全访问 + + ) : ( + request.permissions.can_read && ( + + + 只读访问 + + ) + )} + + {request.expires_at && ( +
+ + 到期时间: {new Date(request.expires_at).toLocaleDateString()} + +
+ )} + + {request.response_message && ( +
+ 审批意见: {request.response_message} +
+ )} +
+
+ {request.status === 'pending' ? ( + <> + + + + ) : ( +
+ {request.status === 'approved' ? '已批准' : '已拒绝'} + {request.updated_at && ( + + {new Date(request.updated_at).toLocaleDateString()} + + )} +
+ )} +
+
+ ))} +
+ + {/* 分页控件 */} + {renderPagination()} + + {/* 使用新的滑动面板组件 */} + handleOpenResponseInput(id, true)} + onReject={(id) => handleOpenResponseInput(id, false)} + processingId={processingId} + approveRejectStatus={approveRejectStatus} + isApproving={isApproving} + /> + + {/* 回复输入弹窗 */} + {showResponseInput && ( +
+
+
+
+
{isApproving ? '批准' : '拒绝'}申请
+ +
+
+
+ + +
+
+
+ + +
+
+
+
+
+ )} + + ); +} diff --git a/src/pages/Permissions/components/RequestDetailSlideOver.jsx b/src/pages/Permissions/components/RequestDetailSlideOver.jsx new file mode 100644 index 0000000..7a7183b --- /dev/null +++ b/src/pages/Permissions/components/RequestDetailSlideOver.jsx @@ -0,0 +1,178 @@ +import React from 'react'; +import SvgIcon from '../../../components/SvgIcon'; + +export default function RequestDetailSlideOver({ + show, + onClose, + request, + onApprove, + onReject, + processingId, + approveRejectStatus, + isApproving, +}) { + if (!request) return null; + + // 获取申请人信息 + const applicantName = request.applicant?.name || request.applicant?.username || '未知用户'; + const applicantDept = request.applicant?.department || '未分配部门'; + const applicantInitial = applicantName.charAt(0); + const knowledgeBaseName = request.knowledge_base?.name || '未知知识库'; + const knowledgeBaseId = request.knowledge_base?.id || ''; + const knowledgeBaseType = request.knowledge_base?.type || ''; + + return ( + <> +
+
+
+
+
申请详情
+ +
+
+
+
申请人信息
+
+
{applicantInitial}
+
+
{applicantName}
+
{applicantDept}
+
+
+
+ +
+
知识库信息
+

+ 名称: {knowledgeBaseName} +

+

+ ID: {knowledgeBaseId} +

+ {knowledgeBaseType && ( +

+ 类型: {knowledgeBaseType} +

+ )} +
+ +
+
申请权限
+
+ {request.permissions?.can_read && !request.permissions?.can_edit && ( + + + 只读 + + )} + {request.permissions?.can_edit && ( + + + 编辑 + + )} + {request.permissions?.can_delete && ( + + + 删除 + + )} +
+
+ +
+
申请时间
+

{new Date(request.created_at || Date.now()).toLocaleString()}

+
+ + {request.expires_at && ( +
+
到期时间
+

{new Date(request.expires_at).toLocaleString()}

+
+ )} + + {request.status && ( +
+
申请状态
+

+ {request.status === 'pending' ? ( + 待处理 + ) : request.status === 'approved' ? ( + 已批准 + ) : ( + 已拒绝 + )} +

+
+ )} + + {request.approver && ( +
+
审批人
+

+ {request.approver.name || request.approver.username} ({request.approver.department || '未分配部门'}) +

+
+ )} + + {request.response_message && ( +
+
审批意见
+
{request.response_message}
+
+ )} + +
+
申请理由
+
+ {request.reason || '无申请理由'} +
+
+
+ {request.status === 'pending' && ( +
+ + +
+ )} +
+
+ + ); +} diff --git a/src/pages/Permissions/components/UserPermissionDetails.jsx b/src/pages/Permissions/components/UserPermissionDetails.jsx new file mode 100644 index 0000000..5f314fd --- /dev/null +++ b/src/pages/Permissions/components/UserPermissionDetails.jsx @@ -0,0 +1,300 @@ +import React, { useState, useEffect } from 'react'; +import { get, post } from '../../../services/api'; + +export default function UserPermissionDetails({ user, onClose, onSave }) { + const [updatedPermissions, setUpdatedPermissions] = useState({}); + const [userPermissions, setUserPermissions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [savingPermissions, setSavingPermissions] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); + + useEffect(() => { + // If we already have the permissions data in the user object, use it + if (user.permissions && Array.isArray(user.permissions)) { + setUserPermissions(user.permissions); + setLoading(false); + } + }, [user]); + + const handlePermissionChange = (knowledgeBaseId, newPermissionType) => { + setUpdatedPermissions((prev) => ({ + ...prev, + [knowledgeBaseId]: newPermissionType, + })); + }; + + const handleSave = async () => { + setSavingPermissions(true); + setError(null); + setSuccessMessage(null); + + try { + const permissionUpdates = Object.entries(updatedPermissions).map( + async ([knowledgeBaseId, permissionType]) => { + const permissions = { + can_read: permissionType !== 'none', + can_edit: ['edit', 'admin'].includes(permissionType), + can_delete: permissionType === 'admin', + }; + + const requestBody = { + user_id: user.user_info.id, + knowledge_base_id: knowledgeBaseId, + permissions: permissions, + // Optional expiration date - can be added if needed + // expires_at: "2025-12-31T23:59:59Z" + }; + + try { + const response = await post('/permissions/update_permission/', requestBody); + return response; + } catch (err) { + throw new Error(`更新知识库 ${knowledgeBaseId} 权限失败: ${err.message || '未知错误'}`); + } + } + ); + + await Promise.all(permissionUpdates); + + setSuccessMessage('权限更新成功'); + // Reset updated permissions + setUpdatedPermissions({}); + + // Notify parent component if needed + if (onSave) { + onSave(user.user_info.id); + } + } catch (err) { + setError(err.message || '更新权限时发生错误'); + } finally { + setSavingPermissions(false); + } + }; + + // 获取权限类型的显示文本 + const getPermissionTypeText = (permissionType) => { + switch (permissionType) { + case 'none': + return '无权限'; + case 'read': + return '只读访问'; + case 'edit': + return '编辑权限'; + case 'admin': + return '管理权限'; + default: + return '未知权限'; + } + }; + + // 获取权限类型的值 + const getPermissionType = (permission) => { + if (!permission) return 'none'; + if (permission.can_delete) return 'admin'; + if (permission.can_edit) return 'edit'; + if (permission.can_read) return 'read'; + return 'none'; + }; + + return ( +
+
+
+
+
{user.user_info.name} 的权限详情
+ +
+
+ {successMessage && ( +
+ {successMessage} + +
+ )} + + {loading ? ( +
+
+ 加载中... +
+

加载权限详情...

+
+ ) : error ? ( +
+
+ {error} +
+ {userPermissions.length > 0 && ( +
+ + + + + + + + + + + + {userPermissions.map((item) => { + const currentPermissionType = getPermissionType(item.permissions); + const updatedPermissionType = + updatedPermissions[item.knowledge_base.id] || + currentPermissionType; + + return ( + + + + + + + + ); + })} + +
知识库名称所属部门当前权限最后访问时间操作
{item.knowledge_base.name}{item.knowledge_base.department || '未指定'} + + {getPermissionTypeText(currentPermissionType)} + + + {item.granted_at + ? new Date(item.granted_at).toLocaleString() + : '从未访问'} + + +
+
+ )} +
+ ) : userPermissions.length === 0 ? ( +
+ 该用户暂无任何知识库权限 +
+ ) : ( +
+ + + + + + + + + + + + {userPermissions.map((item) => { + const currentPermissionType = getPermissionType(item.permissions); + const updatedPermissionType = + updatedPermissions[item.knowledge_base.id] || currentPermissionType; + + return ( + + + + + + + + ); + })} + +
知识库名称所属部门当前权限授权时间操作
{item.knowledge_base.name}{item.knowledge_base.department || '未指定'} + + {getPermissionTypeText(currentPermissionType)} + + + {item.granted_at + ? new Date(item.granted_at).toLocaleString() + : '未记录'} + + +
+
+ )} +
+
+ + +
+
+
+
+
+ ); +} diff --git a/src/pages/Permissions/components/UserPermissions.css b/src/pages/Permissions/components/UserPermissions.css new file mode 100644 index 0000000..1e1dd18 --- /dev/null +++ b/src/pages/Permissions/components/UserPermissions.css @@ -0,0 +1,210 @@ +.search-box { + position: relative; +} + +.search-input { + padding: 8px 12px; + border: 1px solid #d9d9d9; + border-radius: 4px; + width: 240px; + font-size: 14px; + outline: none; + transition: all 0.3s; +} + +.search-input:focus { + border-color: #1890ff; + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); +} + +.user-permissions-table { + background-color: #fff; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.table-header { + display: flex; + background-color: #fafafa; + border-bottom: 1px solid #f0f0f0; + padding: 12px 16px; + font-weight: 600; + color: #333; +} + +.header-cell { + flex: 1; +} + +.header-cell:first-child { + flex: 1.5; +} + +.table-row { + display: flex; + padding: 12px 16px; + border-bottom: 1px solid #f0f0f0; + transition: background-color 0.3s; +} + +.table-row:hover { + background-color: #fafafa; +} + +.table-row:last-child { + border-bottom: none; +} + +.cell { + flex: 1; + display: flex; + align-items: center; +} + +.cell:first-child { + flex: 1.5; +} + +.user-cell { + display: flex; + align-items: center; + gap: 12px; +} + +.avatar-placeholder { + width: 36px; + height: 36px; + border-radius: 50%; + background-color: #1890ff; + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: 16px; +} + +.user-info { + display: flex; + flex-direction: column; +} + +.user-name { + font-weight: 500; + color: #333; +} + +.user-username { + font-size: 12px; + color: #999; +} + +.permission-badges { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.permission-badge { + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; +} + +.permission-badge.read { + background-color: #e6f7ff; + color: #1890ff; + border: 1px solid #91d5ff; +} + +.permission-badge.edit { + background-color: #f6ffed; + color: #52c41a; + border: 1px solid #b7eb8f; +} + +.permission-badge.admin { + background-color: #fff2f0; + color: #ff4d4f; + border: 1px solid #ffccc7; +} + +.action-cell { + justify-content: flex-end; +} + +.btn-details { + background-color: transparent; + border: 1px solid #1890ff; + color: #1890ff; + padding: 4px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; +} + +.btn-details:hover { + background-color: #e6f7ff; +} + +/* 分页控件样式 */ +.pagination-container { + display: flex; + justify-content: center; + align-items: center; + margin-top: 20px; + padding: 10px 0; +} + +.pagination-button { + background-color: #fff; + border: 1px solid #d9d9d9; + color: #333; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; +} + +.pagination-button:hover:not(:disabled) { + border-color: #1890ff; + color: #1890ff; +} + +.pagination-button:disabled { + color: #d9d9d9; + cursor: not-allowed; +} + +.pagination-info { + margin: 0 15px; + color: #666; + font-size: 14px; +} + +/* 页面大小选择器样式 */ +.page-size-selector { + margin-left: 20px; + display: flex; + align-items: center; +} + +.page-size-select { + padding: 5px 10px; + border: 1px solid #d9d9d9; + border-radius: 4px; + background-color: #fff; + font-size: 14px; + color: #333; + cursor: pointer; + outline: none; + transition: all 0.3s; +} + +.page-size-select:hover, .page-size-select:focus { + border-color: #1890ff; +} \ No newline at end of file diff --git a/src/pages/Permissions/components/UserPermissions.jsx b/src/pages/Permissions/components/UserPermissions.jsx new file mode 100644 index 0000000..42dad52 --- /dev/null +++ b/src/pages/Permissions/components/UserPermissions.jsx @@ -0,0 +1,373 @@ +import React, { useState, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { fetchAllUserPermissions, updateUserPermissions } from '../../../store/permissions/permissions.thunks'; +import UserPermissionDetails from './UserPermissionDetails'; +import './UserPermissions.css'; +import SvgIcon from '../../../components/SvgIcon'; + +// 每页显示选项 +const PAGE_SIZE_OPTIONS = [5, 10, 15, 20, 50, 100]; + +export default function UserPermissions() { + const dispatch = useDispatch(); + const [selectedUser, setSelectedUser] = useState(null); + const [showDetailsModal, setShowDetailsModal] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); // 'all', 'pending', 'approved', 'rejected' + + // 分页状态 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + + // 从 Redux store 获取用户列表和状态 + const { + results: users, + status: loading, + error, + total, + page, + page_size, + } = useSelector((state) => state.permissions.allUsersPermissions); + + // 获取用户列表 + useEffect(() => { + dispatch(fetchAllUserPermissions({ page: currentPage, page_size: pageSize })); + }, [dispatch, currentPage, pageSize]); + + // 计算总页数 + const totalPages = Math.ceil(total / page_size); + + const handleOpenDetailsModal = (data) => { + setSelectedUser(data); + setShowDetailsModal(true); + }; + + const handleCloseDetailsModal = () => { + setSelectedUser(null); + setShowDetailsModal(false); + }; + + const handleSavePermissions = async (userId) => { + try { + // Permission updates are now handled directly in the UserPermissionDetails component + // Just refresh the users list to reflect the updated permissions + handleCloseDetailsModal(); + dispatch(fetchAllUserPermissions({ page: currentPage, page_size: pageSize })); + } catch (error) { + console.error('更新权限失败:', error); + } + }; + + const handleSearchChange = (e) => { + setSearchTerm(e.target.value); + setCurrentPage(1); // 重置到第一页 + }; + + const handleStatusFilterChange = (e) => { + setStatusFilter(e.target.value); + setCurrentPage(1); // 重置到第一页 + }; + + const handlePageChange = (page) => { + if (page > 0 && page <= totalPages) { + setCurrentPage(page); + } + }; + + const handlePageSizeChange = (e) => { + const newPageSize = parseInt(e.target.value); + setPageSize(newPageSize); + setCurrentPage(1); // 重置到第一页 + }; + + // 过滤用户列表 + const getFilteredUsers = () => { + let filtered = users; + + // 应用状态过滤 + if (statusFilter !== 'all') { + filtered = filtered.filter((user) => + user.permissions.some((permission) => permission.status === statusFilter) + ); + } + + // 应用搜索过滤 + if (searchTerm.trim()) { + filtered = filtered.filter( + (user) => + user.user_info?.username.toLowerCase().includes(searchTerm.toLowerCase()) || + user.user_info?.name.toLowerCase().includes(searchTerm.toLowerCase()) || + user.user_info?.department.toLowerCase().includes(searchTerm.toLowerCase()) || + user.user_info?.role.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + return filtered; + }; + + const filteredUsers = getFilteredUsers(); + + const renderPagination = () => { + const pageNumbers = []; + const ellipsis = ( +
  • + ... +
  • + ); + + let startPage = Math.max(1, currentPage - 2); + let endPage = Math.min(totalPages, startPage + 4); + + if (endPage - startPage < 4) { + startPage = Math.max(1, endPage - 4); + } + + // 总是显示第一页 + if (startPage > 1) { + pageNumbers.push( +
  • + +
  • + ); + + // 如果第一页和起始页之间有间隔,显示省略号 + if (startPage > 2) { + pageNumbers.push(ellipsis); + } + } + + // 添加页码按钮 + for (let i = startPage; i <= endPage; i++) { + pageNumbers.push( +
  • + +
  • + ); + } + + // 如果末页和最后一页之间有间隔,显示省略号 + if (endPage < totalPages - 1) { + pageNumbers.push(ellipsis); + } + + // 如果末页不是最后一页,显示最后一页 + if (endPage < totalPages) { + pageNumbers.push( +
  • + +
  • + ); + } + + return ( +
    +
    + 每页显示: + + + 总计 {total} 条记录 + +
    + + +
    + ); + }; + + return ( + <> +
    +
    用户权限管理
    +
    + + <> +
    +
    +
    +
    + + + + +
    +
    + +
    +
    + + {loading === 'loading' ? ( +
    +
    + 加载中... +
    +

    加载用户权限列表...

    +
    + ) : error ? ( +
    + {error} +
    + ) : ( + <> +
    + + + + + + + + + + + + + + {filteredUsers.length > 0 ? ( + filteredUsers.map((userPermission) => ( + + + + + + + + + + )) + ) : ( + + + + )} + +
    ID用户名姓名部门角色权限类型操作
    {userPermission.user_info?.id} + {userPermission.user_info?.username || 'N/A'} + + {userPermission.user_info?.name || 'N/A'} + + {userPermission.user_info?.department || 'N/A'} + + + {userPermission.user_info?.role === 'admin' + ? '管理员' + : userPermission.user_info?.role === 'leader' + ? '组长' + : '成员'} + + +
    + {userPermission.stats?.by_permission?.full_access > 0 && ( + + 完全访问:{' '} + {userPermission.stats?.by_permission?.full_access} + + )} + + {userPermission.stats?.by_permission?.read_only > 0 && ( + + 只读访问:{' '} + {userPermission.stats?.by_permission?.read_only} + + )} + + {userPermission.stats?.by_permission?.read_write > 0 && ( + + 读写权限:{' '} + {userPermission.stats?.by_permission?.read_write} + + )} +
    +
    + +
    + 无匹配的用户记录 +
    +
    + + {totalPages > 1 && renderPagination()} + + )} + + {showDetailsModal && selectedUser && ( + + )} + + + ); +} diff --git a/src/pages/auth/Login.jsx b/src/pages/auth/Login.jsx new file mode 100644 index 0000000..132f965 --- /dev/null +++ b/src/pages/auth/Login.jsx @@ -0,0 +1,120 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Link, useNavigate } from 'react-router-dom'; +import { checkAuthThunk, loginThunk } from '../../store/auth/auth.thunk'; +import { showNotification } from '../../store/notification.slice'; + +export default function Login() { + const dispatch = useDispatch(); + const navigate = useNavigate(); + const [username, setUsername] = useState('leader2'); + const [password, setPassword] = useState('leader123'); + const [errors, setErrors] = useState({}); + const [submitted, setSubmitted] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const { user } = useSelector((state) => state.auth); + + useEffect(() => { + handleCheckAuth(); + }, [dispatch]); + + const handleCheckAuth = async () => { + console.log('login page handleCheckAuth'); + try { + await dispatch(checkAuthThunk()).unwrap(); + if (user) navigate('/'); + } catch (error) { + // 检查登录状态失败,不需要显示通知 + } + }; + + const validateForm = () => { + const newErrors = {}; + if (!username) { + newErrors.username = '请输入用户名'; + } + if (!password) { + newErrors.password = '请输入密码'; + } else if (password.length < 6) { + newErrors.password = '密码长度不能少于6个字符'; + } + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setSubmitted(true); + + if (validateForm()) { + setIsLoading(true); + try { + await dispatch(loginThunk({ username, password })).unwrap(); + navigate('/'); + } catch (error) { + // 登录失败的错误通知已在thunk中处理 + console.error('Login failed:', error); + } finally { + setIsLoading(false); + } + } + }; + + return ( +
    +
    OOIN 智能知识库
    +
    +
    + setUsername(e.target.value.trim())} + > + {submitted && errors.username &&
    {errors.username}
    } +
    +
    + setPassword(e.target.value.trim())} + > + {submitted && errors.password &&
    {errors.password}
    } +
    + + 忘记密码? + + +
    + + 没有账号?去注册 + +
    + ); +} diff --git a/src/pages/auth/Signup.jsx b/src/pages/auth/Signup.jsx new file mode 100644 index 0000000..b57e977 --- /dev/null +++ b/src/pages/auth/Signup.jsx @@ -0,0 +1,270 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Link, useNavigate } from 'react-router-dom'; +import { checkAuthThunk, signupThunk } from '../../store/auth/auth.thunk'; + +// 部门和组别映射关系 +const departmentGroups = { + '技术部': ['开发组', '测试组', '运维组'], + '产品部': ['产品规划组', '用户研究组', '交互设计组', '项目管理组'], + '市场部': ['品牌推广组', '市场调研组', '客户关系组', '社交媒体组'], + '行政部': ['人事组', '财务组', '行政管理组', '后勤组'] +}; + +export default function Signup() { + const dispatch = useDispatch(); + const navigate = useNavigate(); + const [formData, setFormData] = useState({ + username: '', + email: '', + password: '', + name: '', + role: 'member', + department: '', + group: '', + }); + const [errors, setErrors] = useState({}); + const [submitted, setSubmitted] = useState(false); + const [availableGroups, setAvailableGroups] = useState([]); + + const { user, loading } = useSelector((state) => state.auth); + + useEffect(() => { + handleCheckAuth(); + }, [dispatch]); + + // 当部门变化时,更新可选的组别 + useEffect(() => { + if (formData.department && departmentGroups[formData.department]) { + setAvailableGroups(departmentGroups[formData.department]); + // 如果已选择的组别不在新部门的选项中,则重置组别 + if (!departmentGroups[formData.department].includes(formData.group)) { + setFormData(prev => ({ + ...prev, + group: '' + })); + } + } else { + setAvailableGroups([]); + setFormData(prev => ({ + ...prev, + group: '' + })); + } + }, [formData.department]); + + const handleCheckAuth = async () => { + console.log('signup page handleCheckAuth'); + try { + await dispatch(checkAuthThunk()).unwrap(); + if (user) navigate('/'); + } catch (error) {} + }; + + const handleInputChange = (e) => { + const { name, value } = e.target; + setFormData({ + ...formData, + [name]: value, + }); + + // 清除对应的错误信息 + if (errors[name]) { + setErrors({ + ...errors, + [name]: '', + }); + } + }; + + const validateForm = () => { + const newErrors = {}; + if (!formData.username) { + newErrors.username = 'Username is required'; + } + + if (!formData.email) { + newErrors.email = 'Email is required'; + } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(formData.email)) { + newErrors.email = 'Invalid email address'; + } + + if (!formData.password) { + newErrors.password = 'Password is required'; + } else if (formData.password.length < 6) { + newErrors.password = 'Password must be at least 6 characters'; + } + + if (!formData.name) { + newErrors.name = 'Name is required'; + } + + if (!formData.department) { + newErrors.department = '请选择部门'; + } + + if (!formData.group) { + newErrors.group = '请选择组别'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setSubmitted(true); + + if (validateForm()) { + console.log('Form submitted successfully!'); + console.log('Registration data:', formData); + try { + await dispatch(signupThunk(formData)).unwrap(); + navigate('/login'); + } catch (error) { + console.error('Signup failed:', error); + } + } + }; + + return ( +
    +
    OOIN 智能知识库
    +
    +
    + + {submitted && errors.username &&
    {errors.username}
    } +
    +
    + + {submitted && errors.email &&
    {errors.email}
    } +
    +
    + + {submitted && errors.password &&
    {errors.password}
    } +
    +
    + + {submitted && errors.name &&
    {errors.name}
    } +
    +
    + + {submitted && errors.department &&
    {errors.department}
    } +
    +
    + + {submitted && errors.group &&
    {errors.group}
    } +
    +
    + +
    + +
    + + 已有账号?立即登录 + +
    + ); +} diff --git a/src/router.jsx b/src/router.jsx deleted file mode 100644 index 125c0a5..0000000 --- a/src/router.jsx +++ /dev/null @@ -1,23 +0,0 @@ -import React, { Suspense } from 'react'; -import { Route, Routes } from 'react-router-dom'; -import Mainlayout from './layouts/Mainlayout'; -import KnowledgeBase from './pages/KnowledgeBase/KnowledgeBase'; -import Loading from './components/Loading'; - -function AppRouter() { - return ( - // }> - - - - - } - /> - - // - ); -} -export default AppRouter; diff --git a/src/router/protectedRoute.jsx b/src/router/protectedRoute.jsx new file mode 100644 index 0000000..ac4c9d8 --- /dev/null +++ b/src/router/protectedRoute.jsx @@ -0,0 +1,12 @@ +import { useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { Navigate, Outlet } from 'react-router-dom'; + +function ProtectedRoute() { + const { user } = useSelector((state) => state.auth); + + return + // return !!user ? : ; +} + +export default ProtectedRoute; diff --git a/src/router/router.jsx b/src/router/router.jsx new file mode 100644 index 0000000..7e4f11a --- /dev/null +++ b/src/router/router.jsx @@ -0,0 +1,92 @@ +import React, { Suspense } from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import Mainlayout from '../layouts/Mainlayout'; +import KnowledgeBase from '../pages/KnowledgeBase/KnowledgeBase'; +import KnowledgeBaseDetail from '../pages/KnowledgeBase/Detail/KnowledgeBaseDetail'; +import Chat from '../pages/Chat/Chat'; +import PermissionsPage from '../pages/Permissions/PermissionsPage'; +import Loading from '../components/Loading'; +import Login from '../pages/Auth/Login'; +import Signup from '../pages/Auth/Signup'; +import ProtectedRoute from './protectedRoute'; +import { useSelector } from 'react-redux'; +import NotificationSnackbar from '../components/NotificationSnackbar'; + +function AppRouter() { + const { user } = useSelector((state) => state.auth); + + // 检查用户是否有管理权限(leader 或 admin) + const hasManagePermission = user && (user.role === 'leader' || user.role === 'admin'); + + return ( + }> + + + + }> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + {/* 权限管理页面路由 - 仅对 leader 或 admin 角色可见 */} + + + + } + /> + + } /> + } /> + } /> + + + ); +} +export default AppRouter; diff --git a/src/services/api.js b/src/services/api.js index 1f003dc..13a9291 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -1,14 +1,27 @@ import axios from 'axios'; +import CryptoJS from 'crypto-js'; +import { mockGet, mockPost, mockPut, mockDelete } from './mockApi'; + +const secretKey = import.meta.env.VITE_SECRETKEY; + +// API连接状态 +let isServerDown = false; +let hasCheckedServer = false; // Create Axios instance with base URL const api = axios.create({ - baseURL: 'api', + baseURL: '/api', withCredentials: true, // Include cookies if needed }); // Request Interceptor api.interceptors.request.use( (config) => { + const encryptedToken = sessionStorage.getItem('token') || ''; + if (encryptedToken) { + const decryptedToken = CryptoJS.AES.decrypt(encryptedToken, secretKey).toString(CryptoJS.enc.Utf8); + config.headers.Authorization = `Token ${decryptedToken}`; + } return config; }, (error) => { @@ -20,14 +33,27 @@ api.interceptors.request.use( // Response Interceptor api.interceptors.response.use( (response) => { + // 如果成功收到响应,表示服务器正常工作 + if (!hasCheckedServer) { + console.log('Server is up and running'); + isServerDown = false; + hasCheckedServer = true; + } return response; }, (error) => { + // 处理服务器无法连接的情况 + if (!error.response || error.code === 'ECONNABORTED' || error.message.includes('Network Error')) { + console.error('Server appears to be down. Switching to mock data.'); + isServerDown = true; + hasCheckedServer = true; + } + // Handle errors in the response if (error.response) { // monitor /verify - if (error.response.status === 401 && error.config.url === '/check-token') { - if (window.location.pathname !== '/login' && window.location.pathname !== '/register') { + if (error.response.status === 401 && error.config.url === '/auth/verify-token/') { + if (window.location.pathname !== '/login' && window.location.pathname !== '/signup') { window.location.href = '/login'; } } @@ -48,44 +74,161 @@ api.interceptors.response.use( } ); -// Define common HTTP methods +// 检查服务器状态 +export const checkServerStatus = async () => { + try { + // await api.get('/health-check', { timeout: 3000 }); + isServerDown = false; + hasCheckedServer = true; + console.log('Server connection established'); + return true; + } catch (error) { + isServerDown = true; + hasCheckedServer = true; + console.error('Server connection failed, using mock data'); + return false; + } +}; + +// 初始检查服务器状态 +checkServerStatus(); + +// Define common HTTP methods with fallback to mock API const get = async (url, params = {}) => { - const res = await api.get(url, { params }); - return res.data; + try { + if (isServerDown) { + console.log(`[MOCK MODE] GET ${url}`); + return await mockGet(url, params); + } + + const res = await api.get(url, { ...params }); + return res.data; + } catch (error) { + if (!hasCheckedServer || (error.request && !error.response)) { + console.log(`Failed to connect to server. Falling back to mock API for GET ${url}`); + return await mockGet(url, params); + } + throw error; + } }; -// Handle POST requests for JSON data +// Handle POST requests for JSON data with fallback to mock API const post = async (url, data, isMultipart = false) => { - const headers = isMultipart - ? { 'Content-Type': 'multipart/form-data' } // For file uploads - : { 'Content-Type': 'application/json' }; // For JSON data + try { + if (isServerDown) { + console.log(`[MOCK MODE] POST ${url}`); + return await mockPost(url, data); + } - const res = await api.post(url, data, { headers }); - return res.data; + const headers = isMultipart + ? { 'Content-Type': 'multipart/form-data' } // For file uploads + : { 'Content-Type': 'application/json' }; // For JSON data + + const res = await api.post(url, data, { headers }); + return res.data; + } catch (error) { + if (!hasCheckedServer || (error.request && !error.response)) { + console.log(`Failed to connect to server. Falling back to mock API for POST ${url}`); + return await mockPost(url, data); + } + throw error; + } }; -// Handle PUT requests +// Handle PUT requests with fallback to mock API const put = async (url, data) => { - const res = await api.put(url, data, { - headers: { 'Content-Type': 'application/json' }, - }); - return res.data; + try { + if (isServerDown) { + console.log(`[MOCK MODE] PUT ${url}`); + return await mockPut(url, data); + } + + const res = await api.put(url, data, { + headers: { 'Content-Type': 'application/json' }, + }); + return res.data; + } catch (error) { + if (!hasCheckedServer || (error.request && !error.response)) { + console.log(`Failed to connect to server. Falling back to mock API for PUT ${url}`); + return await mockPut(url, data); + } + throw error; + } }; -// Handle DELETE requests +// Handle DELETE requests with fallback to mock API const del = async (url) => { - const res = await api.delete(url); - return res.data; + try { + if (isServerDown) { + console.log(`[MOCK MODE] DELETE ${url}`); + return await mockDelete(url); + } + + const res = await api.delete(url); + return res.data; + } catch (error) { + if (!hasCheckedServer || (error.request && !error.response)) { + console.log(`Failed to connect to server. Falling back to mock API for DELETE ${url}`); + return await mockDelete(url); + } + throw error; + } }; const upload = async (url, data) => { - const axiosInstance = await axios.create({ - baseURL: '/api', - headers: { - 'Content-Type': 'multipart/form-data', - }, - }); - const res = await axiosInstance.post(url, data); - return res.data; + try { + if (isServerDown) { + console.log(`[MOCK MODE] Upload ${url}`); + return await mockPost(url, data, true); + } + + const axiosInstance = await axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + const res = await axiosInstance.post(url, data); + return res.data; + } catch (error) { + if (!hasCheckedServer || (error.request && !error.response)) { + console.log(`Failed to connect to server. Falling back to mock API for Upload ${url}`); + return await mockPost(url, data, true); + } + throw error; + } }; + +// 手动切换到模拟API(为调试目的) +export const switchToMockApi = () => { + isServerDown = true; + hasCheckedServer = true; + console.log('Manually switched to mock API'); +}; + +// 手动切换回真实API +export const switchToRealApi = async () => { + // 重新检查服务器状态 + const isServerUp = await checkServerStatus(); + console.log(isServerUp ? 'Switched back to real API' : 'Server still down, continuing with mock API'); + return isServerUp; +}; + +// 权限相关API +export const applyPermission = (data) => { + return post('/permissions/', data); +}; + +export const updatePermission = (data) => { + return post('/permissions/update_permission/', data); +}; + +export const approvePermission = (permissionId) => { + return post(`/permissions/approve_permission/${permissionId}`); +}; + +export const rejectPermission = (permissionId) => { + return post(`/permissions/reject_permission/${permissionId}`); +}; + export { get, post, put, del, upload }; diff --git a/src/services/mockApi.js b/src/services/mockApi.js new file mode 100644 index 0000000..46c9355 --- /dev/null +++ b/src/services/mockApi.js @@ -0,0 +1,990 @@ +// Mock API service for development without backend +import { v4 as uuidv4 } from 'uuid'; + +// Mock data for knowledge bases +const mockKnowledgeBases = [ + { + id: uuidv4(), + user_id: 'user-001', + name: 'Frontend Development', + desc: 'Resources and guides for frontend development including React, Vue, and Angular', + type: 'private', + department: '研发部', + group: '前端开发组', + documents: [], + char_length: 0, + document_count: 0, + external_id: uuidv4(), + create_time: '2024-02-26T08:30:00Z', + update_time: '2024-02-26T14:45:00Z', + permissions: { + can_read: true, + can_edit: true, + can_delete: false, + }, + }, + { + id: uuidv4(), + user_id: 'user-001', + name: 'Backend Technologies', + desc: 'Information about backend frameworks, databases, and server configurations', + type: 'private', + department: '研发部', + group: '后端开发组', + documents: [], + char_length: 0, + document_count: 0, + external_id: uuidv4(), + create_time: '2024-02-25T10:15:00Z', + update_time: '2024-02-26T09:20:00Z', + permissions: { + can_read: true, + can_edit: true, + can_delete: false, + }, + }, + { + id: 'kb-003', + name: 'DevOps Practices', + description: 'Best practices for CI/CD, containerization, and cloud deployment', + created_at: '2023-11-12T15:45:00Z', + updated_at: '2024-02-05T11:30:00Z', + create_time: '2023-11-12T15:45:00Z', + update_time: '2024-02-05T11:30:00Z', + type: 'public', + owner: { + id: 'user-002', + username: 'janedoe', + email: 'jane@example.com', + }, + document_count: 18, + tags: ['docker', 'kubernetes', 'aws'], + permissions: { + can_edit: false, + can_read: true, + }, + }, + { + id: 'kb-004', + name: 'Machine Learning Fundamentals', + description: 'Introduction to machine learning concepts, algorithms, and frameworks', + created_at: '2023-08-20T09:00:00Z', + updated_at: '2024-01-25T16:15:00Z', + create_time: '2023-08-20T09:00:00Z', + update_time: '2024-01-25T16:15:00Z', + type: 'public', + owner: { + id: 'user-003', + username: 'alexsmith', + email: 'alex@example.com', + }, + document_count: 30, + tags: ['ml', 'python', 'tensorflow'], + permissions: { + can_edit: false, + can_read: true, + }, + }, + { + id: 'kb-005', + name: 'UI/UX Design Principles', + description: 'Guidelines for creating effective and user-friendly interfaces', + created_at: '2023-12-01T13:20:00Z', + updated_at: '2024-02-15T10:45:00Z', + create_time: '2023-12-01T13:20:00Z', + update_time: '2024-02-15T10:45:00Z', + type: 'private', + owner: { + id: 'user-002', + username: 'janedoe', + email: 'jane@example.com', + }, + document_count: 12, + tags: ['design', 'ui', 'ux'], + permissions: { + can_edit: true, + can_read: true, + }, + }, + { + id: 'kb-006', + name: 'Mobile App Development', + description: 'Resources for iOS, Android, and cross-platform mobile development', + created_at: '2023-10-25T11:10:00Z', + updated_at: '2024-01-30T14:00:00Z', + create_time: '2023-10-25T11:10:00Z', + update_time: '2024-01-30T14:00:00Z', + type: 'private', + owner: { + id: 'user-001', + username: 'johndoe', + email: 'john@example.com', + }, + document_count: 20, + tags: ['mobile', 'react-native', 'flutter'], + permissions: { + can_edit: true, + can_read: true, + }, + }, + { + id: 'kb-007', + name: 'Cybersecurity Best Practices', + description: 'Guidelines for securing applications, networks, and data', + created_at: '2023-09-18T14:30:00Z', + updated_at: '2024-02-10T09:15:00Z', + create_time: '2023-09-18T14:30:00Z', + update_time: '2024-02-10T09:15:00Z', + type: 'private', + owner: { + id: 'user-004', + username: 'sarahwilson', + email: 'sarah@example.com', + }, + document_count: 25, + tags: ['security', 'encryption', 'authentication'], + permissions: { + can_edit: false, + can_read: false, + }, + }, + { + id: 'kb-008', + name: 'Data Science Toolkit', + description: 'Tools and techniques for data analysis, visualization, and modeling', + created_at: '2023-11-05T10:00:00Z', + updated_at: '2024-01-20T15:30:00Z', + create_time: '2023-11-05T10:00:00Z', + update_time: '2024-01-20T15:30:00Z', + type: 'public', + owner: { + id: 'user-003', + username: 'alexsmith', + email: 'alex@example.com', + }, + document_count: 28, + tags: ['data-science', 'python', 'visualization'], + permissions: { + can_edit: false, + can_read: true, + }, + }, +]; + +// In-memory store for CRUD operations +let knowledgeBases = [...mockKnowledgeBases]; + +// Mock user data for authentication +const mockUsers = [ + { + id: 'user-001', + username: 'leader2', + password: 'leader123', // 在实际应用中不应该存储明文密码 + email: 'admin@example.com', + name: '管理员', + department: '研发部', + group: '前端开发组', + role: 'admin', + avatar: null, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + { + id: 'user-002', + username: 'user', + password: 'user123', // 在实际应用中不应该存储明文密码 + email: 'user@example.com', + name: '普通用户', + department: '市场部', + group: '市场组', + role: 'user', + avatar: null, + created_at: '2024-01-02T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', + }, +]; + +// Helper function for pagination +const paginate = (array, page_size, page) => { + const startIndex = (page - 1) * page_size; + const endIndex = startIndex + page_size; + const items = array.slice(startIndex, endIndex); + + return { + items, + total: array.length, + page, + page_size, + }; +}; + +// Mock chat history data +const mockChatHistory = [ + { + id: 'chat-001', + title: '关于React组件开发的问题', + knowledge_base_id: 'kb-001', + knowledge_base_name: 'Frontend Development', + message_count: 5, + created_at: '2024-03-15T10:30:00Z', + updated_at: '2024-03-15T11:45:00Z', + }, + { + id: 'chat-002', + title: 'Vue.js性能优化讨论', + knowledge_base_id: 'kb-001', + knowledge_base_name: 'Frontend Development', + message_count: 3, + created_at: '2024-03-14T15:20:00Z', + updated_at: '2024-03-14T16:10:00Z', + }, + { + id: 'chat-003', + title: '后端API集成问题', + knowledge_base_id: 'kb-002', + knowledge_base_name: 'Backend Technologies', + message_count: 4, + created_at: '2024-03-13T09:15:00Z', + updated_at: '2024-03-13T10:30:00Z', + }, +]; + +// Mock chat history functions +const mockGetChatHistory = (params) => { + const { page = 1, page_size = 10 } = params; + return paginate(mockChatHistory, page_size, page); +}; + +const mockCreateChat = (data) => { + const newChat = { + id: `chat-${uuidv4().slice(0, 8)}`, + title: data.title || '新对话', + knowledge_base_id: data.knowledge_base_id, + knowledge_base_name: data.knowledge_base_name, + message_count: 0, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + mockChatHistory.unshift(newChat); + return newChat; +}; + +const mockUpdateChat = (id, data) => { + const index = mockChatHistory.findIndex((chat) => chat.id === id); + if (index === -1) { + throw new Error('Chat not found'); + } + const updatedChat = { + ...mockChatHistory[index], + ...data, + updated_at: new Date().toISOString(), + }; + mockChatHistory[index] = updatedChat; + return updatedChat; +}; + +const mockDeleteChat = (id) => { + const index = mockChatHistory.findIndex((chat) => chat.id === id); + if (index === -1) { + throw new Error('Chat not found'); + } + mockChatHistory.splice(index, 1); + return { success: true }; +}; + +// 模拟聊天消息数据 +const chatMessages = {}; + +// 权限申请列表的 mock 数据 +const mockPendingRequests = [ + { + id: 1, + knowledge_base: 'f13c4bdb-eb03-4ce2-b83c-30917351fb72', + applicant: 'f2799611-7a3d-436d-b3fa-3789bdd877e2', + permissions: { + can_edit: false, + can_read: true, + can_delete: false, + }, + status: 'pending', + reason: '需要访问知识库进行学习', + response_message: null, + expires_at: '2025-03-19T00:17:43.781000Z', + created_at: '2025-03-12T00:17:44.044351Z', + updated_at: '2025-03-12T00:17:44.044369Z', + }, + { + id: 2, + knowledge_base: 'f13c4bdb-eb03-4ce2-b83c-30917351fb73', + applicant: 'f2799611-7a3d-436d-b3fa-3789bdd877e3', + permissions: { + can_edit: true, + can_read: true, + can_delete: false, + }, + status: 'pending', + reason: '需要编辑和更新文档', + response_message: null, + expires_at: '2025-03-20T00:17:43.781000Z', + created_at: '2025-03-12T00:17:44.044351Z', + updated_at: '2025-03-12T00:17:44.044369Z', + }, +]; + +// 用户权限列表的 mock 数据 +const mockUserPermissions = [ + { + id: 'perm-001', + user: { + id: 'user-001', + username: 'johndoe', + name: 'John Doe', + email: 'john@example.com', + department: '研发部', + group: '前端开发组', + }, + knowledge_base: { + id: 'kb-001', + name: 'Frontend Development Guide', + }, + permissions: { + can_read: true, + can_edit: true, + can_delete: true, + can_manage: true, + }, + granted_at: '2024-01-15T10:00:00Z', + granted_by: { + id: 'user-admin', + username: 'admin', + name: 'System Admin', + }, + }, + { + id: 'perm-002', + user: { + id: 'user-002', + username: 'janedoe', + name: 'Jane Doe', + email: 'jane@example.com', + department: '研发部', + group: '前端开发组', + }, + knowledge_base: { + id: 'kb-001', + name: 'Frontend Development Guide', + }, + permissions: { + can_read: true, + can_edit: true, + can_delete: false, + can_manage: false, + }, + granted_at: '2024-01-20T14:30:00Z', + granted_by: { + id: 'user-001', + username: 'johndoe', + name: 'John Doe', + }, + }, + { + id: 'perm-003', + user: { + id: 'user-003', + username: 'alexsmith', + name: 'Alex Smith', + email: 'alex@example.com', + department: '研发部', + group: '后端开发组', + }, + knowledge_base: { + id: 'kb-001', + name: 'Frontend Development Guide', + }, + permissions: { + can_read: true, + can_edit: false, + can_delete: false, + can_manage: false, + }, + granted_at: '2024-02-01T09:15:00Z', + granted_by: { + id: 'user-001', + username: 'johndoe', + name: 'John Doe', + }, + }, +]; + +// Mock API handlers for permissions +const mockPermissionApi = { + // 获取待处理的权限申请列表 + getPendingRequests: () => { + return { + code: 200, + message: 'success', + data: { + items: mockPendingRequests, + total: mockPendingRequests.length, + }, + }; + }, + + // 获取用户权限列表 + getUserPermissions: (knowledgeBaseId) => { + const permissions = mockUserPermissions.filter((perm) => perm.knowledge_base.id === knowledgeBaseId); + return { + code: 200, + message: 'success', + data: { + items: permissions, + total: permissions.length, + }, + }; + }, + + // 处理权限申请 + handlePermissionRequest: (requestId, action) => { + const request = mockPendingRequests.find((req) => req.id === requestId); + if (!request) { + return { + code: 404, + message: 'Permission request not found', + }; + } + + request.status = action === 'approve' ? 'approved' : 'rejected'; + + if (action === 'approve') { + // 如果批准,添加新的权限记录 + const newPermission = { + id: `perm-${Date.now()}`, + user: request.user, + knowledge_base: request.knowledge_base, + permissions: { + can_read: true, + can_edit: request.request_type === 'edit', + can_delete: false, + can_manage: false, + }, + granted_at: new Date().toISOString(), + granted_by: mockCurrentUser, + }; + mockUserPermissions.push(newPermission); + } + + return { + code: 200, + message: 'success', + data: request, + }; + }, + + // 更新用户权限 + updateUserPermission: (permissionId, permissions) => { + const permission = mockUserPermissions.find((perm) => perm.id === permissionId); + if (!permission) { + return { + code: 404, + message: 'Permission not found', + }; + } + + permission.permissions = { + ...permission.permissions, + ...permissions, + }; + + return { + code: 200, + message: 'success', + data: permission, + }; + }, + + // 删除用户权限 + deleteUserPermission: (permissionId) => { + const index = mockUserPermissions.findIndex((perm) => perm.id === permissionId); + if (index === -1) { + return { + code: 404, + message: 'Permission not found', + }; + } + + mockUserPermissions.splice(index, 1); + + return { + code: 200, + message: 'success', + }; + }, +}; + +// Mock API functions +export const mockGet = async (url, config = {}) => { + console.log(`[MOCK API] GET ${url}`, config); + + // Simulate network delay + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Get current user + if (url === '/users/me/') { + return { + user: mockUsers[0], // 默认返回第一个用户 + }; + } + + // Get knowledge bases + if (url === '/knowledge-bases/') { + const params = config.params || { page: 1, page_size: 10 }; + const result = paginate(knowledgeBases, params.page_size, params.page); + + return { + data: { + code: 200, + message: '获取知识库列表成功', + data: { + total: result.total, + page: result.page, + page_size: result.page_size, + items: result.items, + }, + }, + }; + } + + // Get knowledge base details + if (url.match(/^\/knowledge-bases\/[^/]+\/$/)) { + const id = url.split('/')[2]; + const knowledgeBase = knowledgeBases.find((kb) => kb.id === id); + + if (!knowledgeBase) { + throw { response: { status: 404, data: { message: 'Knowledge base not found' } } }; + } + + return { + data: { + code: 200, + message: 'success', + data: { + knowledge_base: knowledgeBase, + }, + }, + }; + } + + // Get chat history + if (url === '/chat-history/') { + const params = config.params || { page: 1, page_size: 10 }; + const result = mockGetChatHistory(params); + return { + data: { + code: 200, + message: 'success', + data: result, + }, + }; + } + + // Get chat messages + if (url.match(/^\/chat-history\/[^/]+\/messages\/$/)) { + const chatId = url.split('/')[2]; + + // 如果没有该聊天的消息记录,创建一个空数组 + if (!chatMessages[chatId]) { + chatMessages[chatId] = []; + + // 添加一条欢迎消息 + const chat = mockChatHistory.find((chat) => chat.id === chatId); + if (chat) { + chatMessages[chatId].push({ + id: uuidv4(), + chat_id: chatId, + sender: 'bot', + content: `欢迎使用 ${chat.knowledge_base_name},有什么可以帮助您的?`, + type: 'text', + created_at: new Date().toISOString(), + }); + } + } + + return { + code: 200, + message: '获取成功', + data: { + messages: chatMessages[chatId] || [], + }, + }; + } + + // Knowledge base search + if (url === '/knowledge-bases/search/') { + const { keyword = '', page = 1, page_size = 10 } = config.params || {}; + const filtered = knowledgeBases.filter( + (kb) => + kb.name.toLowerCase().includes(keyword.toLowerCase()) || + kb.description.toLowerCase().includes(keyword.toLowerCase()) || + kb.tags.some((tag) => tag.toLowerCase().includes(keyword.toLowerCase())) + ); + const result = paginate(filtered, page_size, page); + return { + code: 200, + message: 'success', + data: result, + }; + } + + // 用户权限管理 - 获取用户列表 + if (url === '/users/permissions/') { + return { + code: 200, + message: 'success', + data: { + users: mockUsers, + }, + }; + } + + // 用户权限管理 - 获取待处理申请 + if (url === '/permissions/pending/') { + return { + code: 200, + message: 'success', + data: { + items: mockPendingRequests, + total: mockPendingRequests.length, + }, + }; + } + + // 用户权限管理 - 获取用户权限详情 + if (url.match(/\/users\/(.+)\/permissions\//)) { + const userId = url.match(/\/users\/(.+)\/permissions\//)[1]; + + return { + code: 200, + message: 'success', + data: { + permissions: mockUserPermissions[userId] || [], + }, + }; + } + + throw { response: { status: 404, data: { message: 'Not found' } } }; +}; + +export const mockPost = async (url, data) => { + console.log(`[MOCK API] POST ${url}`, data); + + // Simulate network delay + await new Promise((resolve) => setTimeout(resolve, 800)); + + // Login + if (url === '/auth/login/') { + const { username, password } = data; + const user = mockUsers.find((u) => u.username === username && u.password === password); + + if (!user) { + throw { + response: { + status: 401, + data: { + code: 401, + message: '用户名或密码错误', + }, + }, + }; + } + + // 在实际应用中,这里应该生成 JWT token + const token = `mock-jwt-token-${uuidv4()}`; + + return { + code: 200, + message: '登录成功', + data: { + token, + id: user.id, + username: user.username, + email: user.email, + name: user.name, + department: user.department, + group: user.group, + role: user.role, + avatar: user.avatar, + }, + }; + } + + // Create knowledge base + if (url === '/knowledge-bases/') { + const newKnowledgeBase = { + id: `kb-${uuidv4().slice(0, 8)}`, + name: data.name, + description: data.description || '', + desc: data.desc || data.description || '', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + create_time: new Date().toISOString(), + update_time: new Date().toISOString(), + type: data.type || 'private', + department: data.department || null, + group: data.group || null, + owner: { + id: 'user-001', + username: 'johndoe', + email: 'john@example.com', + }, + document_count: 0, + tags: data.tags || [], + permissions: { + can_edit: true, + can_read: true, + }, + documents: [], + }; + + knowledgeBases.push(newKnowledgeBase); + + return { + code: 200, + message: '知识库创建成功', + data: { + knowledge_base: newKnowledgeBase, + external_id: uuidv4(), + }, + }; + } + + // Create new chat + if (url === '/chat-history/') { + const newChat = mockCreateChat(data); + return { + code: 200, + message: 'success', + data: { + chat: newChat, + }, + }; + } + + // Send chat message + if (url.match(/^\/chat-history\/[^/]+\/messages\/$/)) { + const chatId = url.split('/')[2]; + + // 如果没有该聊天的消息记录,创建一个空数组 + if (!chatMessages[chatId]) { + chatMessages[chatId] = []; + } + + // 创建用户消息 + const userMessage = { + id: uuidv4(), + chat_id: chatId, + sender: 'user', + content: data.content, + type: data.type || 'text', + created_at: new Date().toISOString(), + }; + + // 添加用户消息 + chatMessages[chatId].push(userMessage); + + // 创建机器人回复 + const botMessage = { + id: uuidv4(), + chat_id: chatId, + sender: 'bot', + content: `这是对您问题的回复:${data.content}`, + type: 'text', + created_at: new Date(Date.now() + 1000).toISOString(), + }; + + // 添加机器人回复 + chatMessages[chatId].push(botMessage); + + // 更新聊天的最后一条消息和时间 + const chatIndex = mockChatHistory.findIndex((chat) => chat.id === chatId); + if (chatIndex !== -1) { + mockChatHistory[chatIndex].message_count = (mockChatHistory[chatIndex].message_count || 0) + 2; + mockChatHistory[chatIndex].updated_at = new Date().toISOString(); + } + + return { + code: 200, + message: '发送成功', + data: { + user_message: userMessage, + bot_message: botMessage, + }, + }; + } + + // 批准权限申请 + if (url === '/permissions/approve/') { + const { id, responseMessage } = data; + + // 从待处理列表中移除该申请 + mockPendingRequests = mockPendingRequests.filter((request) => request.id !== id); + + return { + code: 200, + message: 'Permission approved successfully', + data: { + id: id, + status: 'approved', + response_message: responseMessage, + }, + }; + } + + // 拒绝权限申请 + if (url === '/permissions/reject/') { + const { id, responseMessage } = data; + + // 从待处理列表中移除该申请 + mockPendingRequests = mockPendingRequests.filter((request) => request.id !== id); + + return { + code: 200, + message: 'Permission rejected successfully', + data: { + id: id, + status: 'rejected', + response_message: responseMessage, + }, + }; + } + + throw { response: { status: 404, data: { message: 'Not found' } } }; +}; + +export const mockPut = async (url, data) => { + console.log(`[MOCK API] PUT ${url}`, data); + + // Simulate network delay + await new Promise((resolve) => setTimeout(resolve, 600)); + + // Update knowledge base + if (url.match(/^\/knowledge-bases\/[^/]+\/$/)) { + const id = url.split('/')[2]; + const index = knowledgeBases.findIndex((kb) => kb.id === id); + + if (index === -1) { + throw { response: { status: 404, data: { message: 'Knowledge base not found' } } }; + } + + const updatedKnowledgeBase = { + ...knowledgeBases[index], + ...data, + updated_at: new Date().toISOString(), + update_time: new Date().toISOString(), + }; + + knowledgeBases[index] = updatedKnowledgeBase; + + // 返回与 mockPost 类似的格式 + return { + code: 200, + message: '知识库更新成功', + data: { + knowledge_base: updatedKnowledgeBase, + external_id: knowledgeBases[index].id, + }, + }; + } + + // Update chat + if (url.match(/^\/chat-history\/[^/]+\/$/)) { + const id = url.split('/')[2]; + return { data: mockUpdateChat(id, data) }; + } + + // 更新用户权限 + if (url.match(/\/users\/(.+)\/permissions\//)) { + const userId = url.match(/\/users\/(.+)\/permissions\//)[1]; + const { permissions } = data; + + // 将权限更新应用到模拟数据 + if (mockUserPermissions[userId]) { + // 遍历permissions对象,更新对应知识库的权限 + Object.entries(permissions).forEach(([knowledgeBaseId, permissionType]) => { + // 查找该用户的该知识库权限 + const permissionIndex = mockUserPermissions[userId].findIndex( + (p) => p.knowledge_base.id === knowledgeBaseId + ); + + if (permissionIndex !== -1) { + // 根据权限类型设置具体权限 + const permission = { + can_read: permissionType === 'read' || permissionType === 'edit' || permissionType === 'admin', + can_edit: permissionType === 'edit' || permissionType === 'admin', + can_admin: permissionType === 'admin', + }; + + // 更新权限 + mockUserPermissions[userId][permissionIndex].permission = permission; + } + }); + } + + return { + code: 200, + message: 'Permissions updated successfully', + data: { + permissions: permissions, + }, + }; + } + + throw { response: { status: 404, data: { message: 'Not found' } } }; +}; + +export const mockDelete = async (url) => { + console.log(`[MOCK API] DELETE ${url}`); + + // Simulate network delay + await new Promise((resolve) => setTimeout(resolve, 800)); + + // Delete knowledge base + if (url.match(/^\/knowledge-bases\/[^/]+\/$/)) { + const id = url.split('/')[2]; + const index = knowledgeBases.findIndex((kb) => kb.id === id); + + if (index === -1) { + throw { response: { status: 404, data: { message: 'Knowledge base not found' } } }; + } + + knowledgeBases.splice(index, 1); + return { success: true }; + } + + // Delete chat + if (url.match(/^\/chat-history\/[^/]+\/$/)) { + const id = url.split('/')[2]; + return { data: mockDeleteChat(id) }; + } + + throw { response: { status: 404, data: { message: 'Not found' } } }; +}; + +// Reset mock data to initial state (useful for testing) +export const resetMockData = () => { + knowledgeBases = [...mockKnowledgeBases]; +}; + +// 添加权限相关的 API 处理 +export const mockApi = { + // ... existing api handlers ... + + // 权限管理相关的 API + 'GET /api/permissions/pending': () => mockPermissionApi.getPendingRequests(), + 'GET /api/permissions/users/:knowledgeBaseId': (params) => + mockPermissionApi.getUserPermissions(params.knowledgeBaseId), + 'POST /api/permissions/handle/:requestId': (params, body) => + mockPermissionApi.handlePermissionRequest(params.requestId, body.action), + 'PUT /api/permissions/:permissionId': (params, body) => + mockPermissionApi.updateUserPermission(params.permissionId, body.permissions), + 'DELETE /api/permissions/:permissionId': (params) => mockPermissionApi.deleteUserPermission(params.permissionId), +}; diff --git a/src/services/permissionService.js b/src/services/permissionService.js new file mode 100644 index 0000000..a77532e --- /dev/null +++ b/src/services/permissionService.js @@ -0,0 +1,56 @@ +import { post } from './api'; + +/** + * 计算权限过期时间 + * @param {string} duration - 权限持续时间,如 '一周', '一个月', '三个月', '六个月', '永久' + * @returns {string} - ISO 格式的日期字符串 + */ +export const calculateExpiresAt = (duration) => { + const now = new Date(); + switch (duration) { + case '一周': + now.setDate(now.getDate() + 7); + break; + case '一个月': + now.setMonth(now.getMonth() + 1); + break; + case '三个月': + now.setMonth(now.getMonth() + 3); + break; + case '六个月': + now.setMonth(now.getMonth() + 6); + break; + case '永久': + // 设置为较远的未来日期 + now.setFullYear(now.getFullYear() + 10); + break; + default: + now.setDate(now.getDate() + 7); + } + return now.toISOString(); +}; + +/** + * 申请知识库访问权限(已废弃,请使用store/knowledgeBase/knowledgeBase.thunks中的requestKnowledgeBaseAccess) + * @deprecated 请使用Redux thunk版本 + * @param {Object} requestData - 请求数据 + * @param {string} requestData.id - 知识库ID + * @param {string} requestData.accessType - 访问类型,如 '只读访问', '编辑权限' + * @param {string} requestData.duration - 访问时长,如 '一周', '一个月' + * @param {string} requestData.reason - 申请原因 + * @returns {Promise} - API 请求的 Promise + */ +export const legacyRequestKnowledgeBaseAccess = async (requestData) => { + const apiRequestData = { + knowledge_base: requestData.id, + permissions: { + can_read: true, + can_edit: requestData.accessType === '编辑权限', + can_delete: false, + }, + reason: requestData.reason, + expires_at: calculateExpiresAt(requestData.duration), + }; + + return post('/permissions/', apiRequestData); +}; diff --git a/src/services/websocket.js b/src/services/websocket.js new file mode 100644 index 0000000..7bfad05 --- /dev/null +++ b/src/services/websocket.js @@ -0,0 +1,234 @@ +import { addNotification, markNotificationAsRead } from '../store/notificationCenter/notificationCenter.slice'; +import store from '../store/store'; // 修改为默认导出 + +// 从环境变量获取 API URL +const API_URL = import.meta.env.VITE_API_URL || ''; +// 将 HTTP URL 转换为 WebSocket URL +const WS_BASE_URL = API_URL.replace(/^http/, 'ws').replace(/\/api\/?$/, ''); + +let socket = null; +let reconnectTimer = null; +let pingInterval = null; +const RECONNECT_DELAY = 5000; // 5秒后尝试重连 +const PING_INTERVAL = 30000; // 30秒发送一次ping + +/** + * 初始化WebSocket连接 + * @returns {Promise} WebSocket连接实例 + */ +export const initWebSocket = () => { + return new Promise((resolve, reject) => { + // 如果已经有一个连接,先关闭它 + if (socket && socket.readyState !== WebSocket.CLOSED) { + socket.close(); + } + + // 清除之前的定时器 + if (reconnectTimer) clearTimeout(reconnectTimer); + if (pingInterval) clearInterval(pingInterval); + + try { + // 从sessionStorage获取token + const encryptedToken = sessionStorage.getItem('token'); + if (!encryptedToken) { + console.error('No token found, cannot connect to notification service'); + reject(new Error('No token found')); + return; + } + + const wsUrl = `${WS_BASE_URL}/ws/notifications?token=${encryptedToken}`; + socket = new WebSocket(wsUrl); + + // 连接建立时的处理 + socket.onopen = () => { + console.log('WebSocket connection established'); + + // 订阅通知频道 + subscribeToNotifications(); + + // 设置定时发送ping消息 + pingInterval = setInterval(() => { + if (socket.readyState === WebSocket.OPEN) { + sendPing(); + } + }, PING_INTERVAL); + + resolve(socket); + }; + + // 接收消息的处理 + socket.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + handleWebSocketMessage(data); + } catch (error) { + console.error('Error parsing WebSocket message:', error); + } + }; + + // 错误处理 + socket.onerror = (error) => { + console.error('WebSocket error:', error); + reject(error); + }; + + // 连接关闭时的处理 + socket.onclose = (event) => { + console.log(`WebSocket connection closed: ${event.code} ${event.reason}`); + + // 清除ping定时器 + if (pingInterval) clearInterval(pingInterval); + + // 如果不是正常关闭,尝试重连 + if (event.code !== 1000) { + reconnectTimer = setTimeout(() => { + console.log('Attempting to reconnect WebSocket...'); + initWebSocket().catch((err) => { + console.error('Failed to reconnect WebSocket:', err); + }); + }, RECONNECT_DELAY); + } + }; + } catch (error) { + console.error('Error initializing WebSocket:', error); + reject(error); + } + }); +}; + +/** + * 订阅通知频道 + */ +export const subscribeToNotifications = () => { + if (socket && socket.readyState === WebSocket.OPEN) { + const subscribeMessage = { + type: 'subscribe', + channel: 'notifications', + }; + socket.send(JSON.stringify(subscribeMessage)); + } +}; + +/** + * 发送ping消息(保持连接活跃) + */ +export const sendPing = () => { + if (socket && socket.readyState === WebSocket.OPEN) { + const pingMessage = { + type: 'ping', + }; + socket.send(JSON.stringify(pingMessage)); + } +}; + +/** + * 确认已读通知 + * @param {string} notificationId 通知ID + */ +export const acknowledgeNotification = (notificationId) => { + if (socket && socket.readyState === WebSocket.OPEN) { + const ackMessage = { + type: 'acknowledge', + notification_id: notificationId, + }; + socket.send(JSON.stringify(ackMessage)); + + // 使用 store.dispatch 替代 dispatch + store.dispatch(markNotificationAsRead(notificationId)); + } +}; + +/** + * 关闭WebSocket连接 + */ +export const closeWebSocket = () => { + if (socket) { + socket.close(1000, 'Normal closure'); + socket = null; + } + + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + if (pingInterval) { + clearInterval(pingInterval); + pingInterval = null; + } +}; + +/** + * 处理接收到的WebSocket消息 + * @param {Object} data 解析后的消息数据 + */ +const handleWebSocketMessage = (data) => { + switch (data.type) { + case 'connection_established': + console.log(`Connection established for user: ${data.user_id}`); + break; + + case 'notification': + console.log('Received notification:', data); + // 将通知添加到Redux store + store.dispatch(addNotification(processNotification(data))); + break; + + case 'pong': + console.log(`Received pong at ${data.timestamp}`); + break; + + case 'error': + console.error(`WebSocket error: ${data.code} - ${data.message}`); + break; + + default: + console.log('Received unknown message type:', data); + } +}; + +/** + * 处理通知数据,转换为应用内通知格式 + * @param {Object} data 通知数据 + * @returns {Object} 处理后的通知数据 + */ +const processNotification = (data) => { + const { data: notificationData } = data; + + let icon = 'bi-info-circle'; + if (notificationData.category === 'system') { + icon = 'bi-info-circle'; + } else if (notificationData.category === 'permission') { + icon = 'bi-shield'; + } + + // 计算时间显示 + const createdAt = new Date(notificationData.created_at); + const now = new Date(); + const diffMs = now - createdAt; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + let timeDisplay; + if (diffMins < 60) { + timeDisplay = `${diffMins}分钟前`; + } else if (diffHours < 24) { + timeDisplay = `${diffHours}小时前`; + } else { + timeDisplay = `${diffDays}天前`; + } + + return { + id: notificationData.id, + type: notificationData.category, + icon, + title: notificationData.title, + content: notificationData.content, + time: timeDisplay, + hasDetail: true, + isRead: notificationData.is_read, + created_at: notificationData.created_at, + metadata: notificationData.metadata || {}, + }; +}; diff --git a/src/store/auth/auth.slice.js b/src/store/auth/auth.slice.js new file mode 100644 index 0000000..e5f2bb4 --- /dev/null +++ b/src/store/auth/auth.slice.js @@ -0,0 +1,63 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { checkAuthThunk, loginThunk, logoutThunk, signupThunk } from './auth.thunk'; + +const setPending = (state) => { + state.loading = true; + state.error = null; + state.user = null; +}; + +const setFulfilled = (state, action) => { + state.user = action.payload; + state.loading = false; + state.error = null; +}; + +const setRejected = (state, action) => { + state.error = action.payload; + state.loading = false; +}; + +const authSlice = createSlice({ + name: 'auth', + initialState: { + loading: false, + error: null, + user: null, + }, + reducers: { + login: (state, action) => { + state.user = action.payload; + }, + logout: (state) => { + state.user = null; + state.error = null; + state.loading = false; + }, + }, + extraReducers: (builder) => { + builder + .addCase(checkAuthThunk.pending, setPending) + .addCase(checkAuthThunk.fulfilled, setFulfilled) + .addCase(checkAuthThunk.rejected, setRejected) + + .addCase(loginThunk.pending, setPending) + .addCase(loginThunk.fulfilled, setFulfilled) + .addCase(loginThunk.rejected, setRejected) + + .addCase(signupThunk.pending, setPending) + .addCase(signupThunk.fulfilled, setFulfilled) + .addCase(signupThunk.rejected, setRejected) + + .addCase(logoutThunk.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(logoutThunk.fulfilled) + .addCase(logoutThunk.rejected, setRejected); + }, +}); + +export const { login, logout } = authSlice.actions; +const authReducer = authSlice.reducer; +export default authReducer; diff --git a/src/store/auth/auth.thunk.js b/src/store/auth/auth.thunk.js new file mode 100644 index 0000000..3c7b20a --- /dev/null +++ b/src/store/auth/auth.thunk.js @@ -0,0 +1,111 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { get, post } from '../../services/api'; +import { showNotification } from '../notification.slice'; +import { logout } from './auth.slice'; +import CryptoJS from 'crypto-js'; + +const secretKey = import.meta.env.VITE_SECRETKEY; + +export const loginThunk = createAsyncThunk( + 'auth/login', + async ({ username, password }, { rejectWithValue, dispatch }) => { + try { + const { message, data, code } = await post('/auth/login/', { username, password }); + console.log('code', code); + + if (code !== 200) { + throw new Error(message || 'Something went wrong'); + } + if (!data) { + throw new Error(message || 'Something went wrong'); + } + const { token } = data; + // encrypt token + const encryptedToken = CryptoJS.AES.encrypt(token, secretKey).toString(); + sessionStorage.setItem('token', encryptedToken); + + return data; + } catch (error) { + const errorMessage = error.response?.data?.message || 'Something went wrong'; + console.log(errorMessage); + dispatch( + showNotification({ + message: errorMessage, + type: 'danger', + }) + ); + return rejectWithValue(errorMessage); + } + } +); + +export const signupThunk = createAsyncThunk('auth/signup', async (userData, { rejectWithValue, dispatch }) => { + try { + // 使用新的注册 API + const response = await post('/auth/register/', userData); + console.log('注册返回数据:', response); + + // 处理新的返回格式 + if (response && response.code === 200) { + // // 将 token 加密存储到 sessionStorage + // const { token } = response.data; + // if (token) { + // const encryptedToken = CryptoJS.AES.encrypt(token, secretKey).toString(); + // sessionStorage.setItem('token', encryptedToken); + // } + + // 显示注册成功通知 + dispatch( + showNotification({ + message: '注册成功', + type: 'success', + }) + ); + return response.data; + } + + return rejectWithValue(response.message || '注册失败'); + } catch (error) { + const errorMessage = error.response?.data?.message || '注册失败,请稍后重试'; + dispatch( + showNotification({ + message: errorMessage, + type: 'danger', + }) + ); + return rejectWithValue(errorMessage); + } +}); + +export const checkAuthThunk = createAsyncThunk('auth/verify', async (_, { rejectWithValue, dispatch }) => { + try { + const { data, message } = await post('/auth/verify-token/'); + const { user } = data; + if (!user) { + dispatch(logout()); + throw new Error(message || 'No token found'); + } + return user; + } catch (error) { + dispatch(logout()); + return rejectWithValue(error.response?.data || 'Token verification failed'); + } +}); + +// Async thunk for logging out +export const logoutThunk = createAsyncThunk('auth/logout', async (_, { rejectWithValue, dispatch }) => { + try { + // Send the logout request to the server (this assumes your server clears any session-related info) + await post('/auth/logout/'); + dispatch(logout()); + } catch (error) { + const errorMessage = error.response?.data?.message || 'Log out failed'; + dispatch( + showNotification({ + message: errorMessage, + type: 'danger', + }) + ); + return rejectWithValue(errorMessage); + } +}); diff --git a/src/store/chat/chat.messages.thunks.js b/src/store/chat/chat.messages.thunks.js new file mode 100644 index 0000000..e59e461 --- /dev/null +++ b/src/store/chat/chat.messages.thunks.js @@ -0,0 +1,45 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { get, post } from '../../services/api'; + +/** + * 获取聊天消息 + * @param {string} chatId - 聊天ID + */ +export const fetchMessages = createAsyncThunk('chat/fetchMessages', async (chatId, { rejectWithValue }) => { + try { + const response = await get(`/chat-history/${chatId}/messages/`); + + // 处理返回格式 + if (response && response.code === 200) { + return response.data.messages; + } + + return response.data?.messages || []; + } catch (error) { + return rejectWithValue(error.response?.data || 'Failed to fetch messages'); + } +}); + +/** + * 发送聊天消息 + * @param {Object} params - 消息参数 + * @param {string} params.chatId - 聊天ID + * @param {string} params.content - 消息内容 + */ +export const sendMessage = createAsyncThunk('chat/sendMessage', async ({ chatId, content }, { rejectWithValue }) => { + try { + const response = await post(`/chat-history/${chatId}/messages/`, { + content, + type: 'text', + }); + + // 处理返回格式 + if (response && response.code === 200) { + return response.data; + } + + return response.data || {}; + } catch (error) { + return rejectWithValue(error.response?.data || 'Failed to send message'); + } +}); diff --git a/src/store/chat/chat.slice.js b/src/store/chat/chat.slice.js new file mode 100644 index 0000000..f74cd04 --- /dev/null +++ b/src/store/chat/chat.slice.js @@ -0,0 +1,340 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { + fetchAvailableDatasets, + fetchChats, + createChat, + updateChat, + deleteChat, + createChatRecord, + fetchConversationDetail, +} from './chat.thunks'; +import { fetchMessages, sendMessage } from './chat.messages.thunks'; + +// 初始状态 +const initialState = { + // Chat history state + history: { + items: [], + status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' + error: null, + }, + // Chat session creation state + createSession: { + status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' + error: null, + sessionId: null, + }, + // Chat messages state + messages: { + items: [], + status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' + error: null, + }, + // Send message state + sendMessage: { + status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' + error: null, + }, + // 可用于聊天的知识库列表 + availableDatasets: { + items: [], + status: 'idle', + error: null, + }, + // 操作状态(创建、更新、删除) + operations: { + status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' + error: null, + }, + // 兼容旧版本的state结构 + list: { + items: [], + total: 0, + page: 1, + page_size: 10, + status: 'idle', + error: null, + }, + // 当前聊天 + currentChat: { + data: null, + status: 'idle', + error: null, + }, +}; + +// 创建 slice +const chatSlice = createSlice({ + name: 'chat', + initialState, + reducers: { + // 重置操作状态 + resetOperationStatus: (state) => { + state.operations.status = 'idle'; + state.operations.error = null; + }, + + // 重置当前聊天 + resetCurrentChat: (state) => { + state.currentChat.data = null; + state.currentChat.status = 'idle'; + state.currentChat.error = null; + }, + + // 设置当前聊天 + setCurrentChat: (state, action) => { + state.currentChat.data = action.payload; + state.currentChat.status = 'succeeded'; + }, + + // 重置消息状态 + resetMessages: (state) => { + state.messages.items = []; + state.messages.status = 'idle'; + state.messages.error = null; + }, + + // 重置发送消息状态 + resetSendMessageStatus: (state) => { + state.sendMessage.status = 'idle'; + state.sendMessage.error = null; + }, + + // 添加消息 + addMessage: (state, action) => { + state.messages.items.push(action.payload); + }, + }, + extraReducers: (builder) => { + // 获取聊天列表 + builder + .addCase(fetchChats.pending, (state) => { + state.list.status = 'loading'; + state.history.status = 'loading'; + }) + .addCase(fetchChats.fulfilled, (state, action) => { + state.list.status = 'succeeded'; + state.list.items = action.payload.results; + state.list.total = action.payload.total; + state.list.page = action.payload.page; + state.list.page_size = action.payload.page_size; + + // 同时更新新的状态结构 + state.history.status = 'succeeded'; + state.history.items = action.payload.results; + state.history.error = null; + }) + .addCase(fetchChats.rejected, (state, action) => { + state.list.status = 'failed'; + state.list.error = action.payload || action.error.message; + + // 同时更新新的状态结构 + state.history.status = 'failed'; + state.history.error = action.payload || action.error.message; + }) + + // 创建聊天 + .addCase(createChat.pending, (state) => { + state.operations.status = 'loading'; + }) + .addCase(createChat.fulfilled, (state, action) => { + state.operations.status = 'succeeded'; + state.list.items.unshift(action.payload); + state.list.total += 1; + state.currentChat.data = action.payload; + state.currentChat.status = 'succeeded'; + }) + .addCase(createChat.rejected, (state, action) => { + state.operations.status = 'failed'; + state.operations.error = action.payload || action.error.message; + }) + + // 删除聊天 + .addCase(deleteChat.pending, (state) => { + state.operations.status = 'loading'; + }) + .addCase(deleteChat.fulfilled, (state, action) => { + state.operations.status = 'succeeded'; + // 更新旧的状态结构 + state.list.items = state.list.items.filter((chat) => chat.id !== action.payload); + // 更新新的状态结构 + state.history.items = state.history.items.filter((chat) => chat.conversation_id !== action.payload); + + if (state.list.total > 0) { + state.list.total -= 1; + } + + if (state.currentChat.data && state.currentChat.data.id === action.payload) { + state.currentChat.data = null; + } + }) + .addCase(deleteChat.rejected, (state, action) => { + state.operations.status = 'failed'; + state.operations.error = action.payload || action.error.message; + }) + + // 更新聊天 + .addCase(updateChat.pending, (state) => { + state.operations.status = 'loading'; + }) + .addCase(updateChat.fulfilled, (state, action) => { + state.operations.status = 'succeeded'; + const index = state.list.items.findIndex((chat) => chat.id === action.payload.id); + if (index !== -1) { + state.list.items[index] = action.payload; + } + if (state.currentChat.data && state.currentChat.data.id === action.payload.id) { + state.currentChat.data = action.payload; + } + }) + .addCase(updateChat.rejected, (state, action) => { + state.operations.status = 'failed'; + state.operations.error = action.payload || action.error.message; + }) + + // 获取聊天消息 + .addCase(fetchMessages.pending, (state) => { + state.messages.status = 'loading'; + state.messages.error = null; + }) + .addCase(fetchMessages.fulfilled, (state, action) => { + state.messages.status = 'succeeded'; + state.messages.items = action.payload; + }) + .addCase(fetchMessages.rejected, (state, action) => { + state.messages.status = 'failed'; + state.messages.error = action.error.message; + }) + + // 发送聊天消息 + .addCase(sendMessage.pending, (state) => { + state.sendMessage.status = 'loading'; + state.sendMessage.error = null; + }) + .addCase(sendMessage.fulfilled, (state, action) => { + state.sendMessage.status = 'succeeded'; + // 更新消息列表 + const index = state.messages.items.findIndex( + (msg) => msg.content === action.payload.content && msg.sender === action.payload.sender + ); + if (index === -1) { + state.messages.items.push(action.payload); + } + }) + .addCase(sendMessage.rejected, (state, action) => { + state.sendMessage.status = 'failed'; + state.sendMessage.error = action.error.message; + }) + + // 处理创建聊天记录 + .addCase(createChatRecord.pending, (state) => { + state.sendMessage.status = 'loading'; + state.sendMessage.error = null; + }) + .addCase(createChatRecord.fulfilled, (state, action) => { + state.sendMessage.status = 'succeeded'; + // 添加新的消息 + state.messages.items.push({ + id: action.payload.id, + role: 'user', + content: action.meta.arg.question, + created_at: new Date().toISOString(), + }); + + // 添加助手回复 + if (action.payload.role === 'assistant' && action.payload.content) { + state.messages.items.push({ + id: action.payload.id, + role: 'assistant', + content: action.payload.content, + created_at: action.payload.created_at, + }); + } + + // 更新聊天记录列表 + const chatExists = state.history.items.some( + (chat) => chat.conversation_id === action.payload.conversation_id + ); + + if (!chatExists) { + const newChat = { + conversation_id: action.payload.conversation_id, + last_message: action.payload.content, + last_time: action.payload.created_at, + datasets: [ + { + id: action.payload.dataset_id, + name: action.payload.dataset_name, + }, + ], + dataset_id_list: action.payload.dataset_id_list, + message_count: 2, // 用户问题和助手回复 + }; + + state.history.items.unshift(newChat); + } else { + // 更新已存在聊天的最后消息和时间 + const chatIndex = state.history.items.findIndex( + (chat) => chat.conversation_id === action.payload.conversation_id + ); + + if (chatIndex !== -1) { + state.history.items[chatIndex].last_message = action.payload.content; + state.history.items[chatIndex].last_time = action.payload.created_at; + state.history.items[chatIndex].message_count += 2; // 新增用户问题和助手回复 + } + } + }) + .addCase(createChatRecord.rejected, (state, action) => { + state.sendMessage.status = 'failed'; + state.sendMessage.error = action.payload || '创建聊天记录失败'; + }) + + // 处理获取可用知识库 + .addCase(fetchAvailableDatasets.pending, (state) => { + state.availableDatasets.status = 'loading'; + state.availableDatasets.error = null; + }) + .addCase(fetchAvailableDatasets.fulfilled, (state, action) => { + state.availableDatasets.status = 'succeeded'; + state.availableDatasets.items = action.payload || []; + state.availableDatasets.error = null; + }) + .addCase(fetchAvailableDatasets.rejected, (state, action) => { + state.availableDatasets.status = 'failed'; + state.availableDatasets.error = action.payload || '获取可用知识库失败'; + }) + + // 获取会话详情 + .addCase(fetchConversationDetail.pending, (state) => { + state.currentChat.status = 'loading'; + state.currentChat.error = null; + }) + .addCase(fetchConversationDetail.fulfilled, (state, action) => { + if (action.payload) { + state.currentChat.status = 'succeeded'; + state.currentChat.data = action.payload; + } else { + state.currentChat.status = 'idle'; + state.currentChat.data = null; + } + }) + .addCase(fetchConversationDetail.rejected, (state, action) => { + state.currentChat.status = 'failed'; + state.currentChat.error = action.payload || action.error.message; + }); + }, +}); + +// 导出 actions +export const { + resetOperationStatus, + resetCurrentChat, + setCurrentChat, + resetMessages, + resetSendMessageStatus, + addMessage, +} = chatSlice.actions; + +// 导出 reducer +export default chatSlice.reducer; diff --git a/src/store/chat/chat.thunks.js b/src/store/chat/chat.thunks.js new file mode 100644 index 0000000..3a29ba8 --- /dev/null +++ b/src/store/chat/chat.thunks.js @@ -0,0 +1,177 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { get, post, put, del } from '../../services/api'; +import { showNotification } from '../notification.slice'; + +/** + * 获取聊天列表 + * @param {Object} params - 查询参数 + * @param {number} params.page - 页码 + * @param {number} params.page_size - 每页数量 + */ +export const fetchChats = createAsyncThunk('chat/fetchChats', async (params = {}, { rejectWithValue }) => { + try { + const response = await get('/chat-history/', { params }); + + // 处理返回格式 + if (response && response.code === 200) { + return { + results: response.data.results, + total: response.data.total, + page: response.data.page || 1, + page_size: response.data.page_size || 10, + }; + } + + return { results: [], total: 0, page: 1, page_size: 10 }; + } catch (error) { + console.error('Error fetching chats:', error); + return rejectWithValue(error.response?.data?.message || 'Failed to fetch chats'); + } +}); + +/** + * 创建新聊天 + * @param {Object} chatData - 聊天数据 + * @param {string} chatData.knowledge_base_id - 知识库ID + * @param {string} chatData.title - 聊天标题 + */ +export const createChat = createAsyncThunk('chat/createChat', async (chatData, { rejectWithValue }) => { + try { + const response = await post('/chat-history/', chatData); + + // 处理返回格式 + if (response && response.code === 200) { + return response.data.chat; + } + + return response.data?.chat || {}; + } catch (error) { + return rejectWithValue(error.response?.data || 'Failed to create chat'); + } +}); + +/** + * 更新聊天 + * @param {Object} params - 更新参数 + * @param {string} params.id - 聊天ID + * @param {Object} params.data - 更新数据 + */ +export const updateChat = createAsyncThunk('chat/updateChat', async ({ id, data }, { rejectWithValue }) => { + try { + const response = await put(`/chat-history/${id}/`, data); + + // 处理返回格式 + if (response && response.code === 200) { + return response.data.chat; + } + + return response.data?.chat || {}; + } catch (error) { + return rejectWithValue(error.response?.data || 'Failed to update chat'); + } +}); + +/** + * 删除聊天 + * @param {string} conversationId - 聊天ID + */ +export const deleteChat = createAsyncThunk('chat/deleteChat', async (conversationId, { rejectWithValue }) => { + try { + const response = await del(`/chat-history/conversation/${conversationId}/`); + + // 处理返回格式 + if (response && response.code === 200) { + return conversationId; + } + + return conversationId; + } catch (error) { + console.error('Error deleting chat:', error); + return rejectWithValue(error.response?.data?.message || '删除聊天失败'); + } +}); + +/** + * 获取可用于聊天的知识库列表 + */ +export const fetchAvailableDatasets = createAsyncThunk( + 'chat/fetchAvailableDatasets', + async (_, { rejectWithValue }) => { + try { + const response = await get('/chat-history/available_datasets/'); + + if (response && response.code === 200) { + return response.data; + } + + return rejectWithValue('获取可用知识库列表失败'); + } catch (error) { + console.error('Error fetching available datasets:', error); + return rejectWithValue(error.response?.data?.message || '获取可用知识库列表失败'); + } + } +); + +/** + * 创建聊天记录 + * @param {Object} params - 聊天参数 + * @param {string[]} params.dataset_id_list - 知识库ID列表 + * @param {string} params.question - 用户问题 + * @param {string} params.conversation_id - 会话ID,可选 + */ +export const createChatRecord = createAsyncThunk('chat/createChatRecord', async (params, { rejectWithValue }) => { + try { + const response = await post('/chat-history/', { + dataset_id_list: params.dataset_id_list, + question: params.question, + conversation_id: params.conversation_id, + }); + + // 处理返回格式 + if (response && response.code === 200) { + return response.data; + } + + return rejectWithValue(response.message || '创建聊天记录失败'); + } catch (error) { + console.error('Error creating chat record:', error); + return rejectWithValue(error.response?.data?.message || '创建聊天记录失败'); + } +}); + +/** + * 获取会话详情 + * @param {string} conversationId - 会话ID + */ +export const fetchConversationDetail = createAsyncThunk( + 'chat/fetchConversationDetail', + async (conversationId, { rejectWithValue, dispatch }) => { + try { + const response = await get('/chat-history/conversation_detail', { + params: { conversation_id: conversationId }, + }); + + if (response && response.code === 200) { + // 如果存在消息,更新Redux状态 + if (response.data.messages) { + dispatch({ + type: 'chat/fetchMessages/fulfilled', + payload: response.data.messages, + }); + } + + return response.data; + } + + return rejectWithValue('获取会话详情失败'); + } catch (error) { + // 如果是新聊天,API会返回404,此时不返回错误 + if (error.response && error.response.status === 404) { + return null; + } + + console.error('Error fetching conversation detail:', error); + return rejectWithValue(error.response?.data?.message || '获取会话详情失败'); + } + } +); diff --git a/src/store/knowledgeBase/knowledgeBase.slice.js b/src/store/knowledgeBase/knowledgeBase.slice.js new file mode 100644 index 0000000..9648e19 --- /dev/null +++ b/src/store/knowledgeBase/knowledgeBase.slice.js @@ -0,0 +1,184 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { + fetchKnowledgeBases, + createKnowledgeBase, + updateKnowledgeBase, + deleteKnowledgeBase, + changeKnowledgeBaseType, + searchKnowledgeBases, + requestKnowledgeBaseAccess, + getKnowledgeBaseById, +} from './knowledgeBase.thunks'; + +const initialState = { + knowledgeBases: [], + currentKnowledgeBase: null, + searchResults: [], + searchLoading: false, + loading: false, + error: null, + pagination: { + total: 0, + page: 1, + page_size: 10, + total_pages: 1, + }, + batchPermissions: {}, + batchLoading: false, + editStatus: 'idle', + requestAccessStatus: 'idle', +}; + +const knowledgeBaseSlice = createSlice({ + name: 'knowledgeBase', + initialState, + reducers: { + clearCurrentKnowledgeBase: (state) => { + state.currentKnowledgeBase = null; + }, + clearSearchResults: (state) => { + state.searchResults = []; + }, + clearEditStatus: (state) => { + state.editStatus = 'idle'; + }, + }, + extraReducers: (builder) => { + builder + // 获取知识库列表 + .addCase(fetchKnowledgeBases.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(fetchKnowledgeBases.fulfilled, (state, action) => { + state.loading = false; + state.knowledgeBases = action.payload.items || []; + state.pagination = { + total: action.payload.total || 0, + page: action.payload.page || 1, + page_size: action.payload.page_size || 10, + total_pages: action.payload.total_pages || 1, + }; + }) + .addCase(fetchKnowledgeBases.rejected, (state, action) => { + state.loading = false; + state.error = action.payload || 'Failed to fetch knowledge bases'; + }) + + // 创建知识库 + .addCase(createKnowledgeBase.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(createKnowledgeBase.fulfilled, (state, action) => { + state.loading = false; + state.editStatus = 'successful'; + // 不需要更新 knowledgeBases,因为创建后会跳转到详情页 + }) + .addCase(createKnowledgeBase.rejected, (state, action) => { + state.loading = false; + state.error = action.payload || 'Failed to create knowledge base'; + state.editStatus = 'failed'; + }) + + // 更新知识库 + .addCase(updateKnowledgeBase.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(updateKnowledgeBase.fulfilled, (state, action) => { + state.loading = false; + state.currentKnowledgeBase = action.payload; + state.editStatus = 'successful'; + }) + .addCase(updateKnowledgeBase.rejected, (state, action) => { + state.loading = false; + state.error = action.payload || 'Failed to update knowledge base'; + state.editStatus = 'failed'; + }) + + // 删除知识库 + .addCase(deleteKnowledgeBase.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(deleteKnowledgeBase.fulfilled, (state, action) => { + state.loading = false; + state.knowledgeBases = state.knowledgeBases.filter((kb) => kb.id !== action.meta.arg.knowledgeBaseId); + }) + .addCase(deleteKnowledgeBase.rejected, (state, action) => { + state.loading = false; + state.error = action.payload || 'Failed to delete knowledge base'; + }) + + // 修改知识库类型 + .addCase(changeKnowledgeBaseType.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(changeKnowledgeBaseType.fulfilled, (state, action) => { + state.loading = false; + if (state.currentKnowledgeBase) { + state.currentKnowledgeBase = { + ...state.currentKnowledgeBase, + type: action.payload.type, + department: action.payload.department, + group: action.payload.group, + }; + } + state.editStatus = 'successful'; + }) + .addCase(changeKnowledgeBaseType.rejected, (state, action) => { + state.loading = false; + state.error = action.payload || 'Failed to change knowledge base type'; + state.editStatus = 'failed'; + }) + + // 搜索知识库 + .addCase(searchKnowledgeBases.pending, (state) => { + state.searchLoading = true; + state.error = null; + }) + .addCase(searchKnowledgeBases.fulfilled, (state, action) => { + state.searchLoading = false; + if (action.payload && action.payload.code === 200) { + state.searchResults = action.payload.data.items || []; + } else { + state.searchResults = action.payload.items || []; + } + }) + .addCase(searchKnowledgeBases.rejected, (state, action) => { + state.searchLoading = false; + state.error = action.payload || 'Failed to search knowledge bases'; + }) + + // 申请知识库访问权限 + .addCase(requestKnowledgeBaseAccess.pending, (state) => { + state.requestAccessStatus = 'loading'; + }) + .addCase(requestKnowledgeBaseAccess.fulfilled, (state) => { + state.requestAccessStatus = 'successful'; + }) + .addCase(requestKnowledgeBaseAccess.rejected, (state) => { + state.requestAccessStatus = 'failed'; + }) + + // 获取知识库详情 + .addCase(getKnowledgeBaseById.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(getKnowledgeBaseById.fulfilled, (state, action) => { + state.loading = false; + state.currentKnowledgeBase = action.payload.knowledge_base || action.payload; + }) + .addCase(getKnowledgeBaseById.rejected, (state, action) => { + state.loading = false; + state.error = action.payload || 'Failed to get knowledge base details'; + }); + }, +}); + +export const { clearCurrentKnowledgeBase, clearSearchResults, clearEditStatus } = knowledgeBaseSlice.actions; + +export default knowledgeBaseSlice.reducer; diff --git a/src/store/knowledgeBase/knowledgeBase.thunks.js b/src/store/knowledgeBase/knowledgeBase.thunks.js new file mode 100644 index 0000000..00cb565 --- /dev/null +++ b/src/store/knowledgeBase/knowledgeBase.thunks.js @@ -0,0 +1,196 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { get, post, put, del } from '../../services/api'; +import { showNotification } from '../notification.slice'; + +/** + * Fetch knowledge bases with pagination + * @param {Object} params - Pagination parameters + * @param {number} params.page - Page number (default: 1) + * @param {number} params.page_size - Page size (default: 10) + */ +export const fetchKnowledgeBases = createAsyncThunk( + 'knowledgeBase/fetchKnowledgeBases', + async ({ page = 1, page_size = 10 } = {}, { rejectWithValue }) => { + try { + const response = await get('/knowledge-bases/', { params: { page, page_size } }); + // 处理新的返回格式 + if (response.data && response.data.code === 200) { + return response.data.data; + } + return response.data; + } catch (error) { + return rejectWithValue(error.response?.data || 'Failed to fetch knowledge bases'); + } + } +); + +/** + * Search knowledge bases + * @param {Object} params - Search parameters + * @param {string} params.keyword - Search keyword + * @param {number} params.page - Page number (default: 1) + * @param {number} params.page_size - Page size (default: 10) + */ +export const searchKnowledgeBases = createAsyncThunk('knowledgeBase/search', async (params, { rejectWithValue }) => { + try { + const { keyword, page = 1, page_size = 10 } = params; + const response = await get('/knowledge-bases/search', { + params: { + keyword, + page, + page_size, + }, + }); + + // 处理新的返回格式 + if (response.data && response.data.code === 200) { + return response.data; + } + return response.data; + } catch (error) { + return rejectWithValue(error.response?.data || error.message); + } +}); + +/** + * Create a new knowledge base + */ +export const createKnowledgeBase = createAsyncThunk( + 'knowledgeBase/createKnowledgeBase', + async (knowledgeBaseData, { rejectWithValue }) => { + try { + const response = await post('/knowledge-bases/', knowledgeBaseData); + + // 处理新的返回格式 + if (response.data && response.data.code === 200) { + return response.data.data.knowledge_base; + } + + return response.data; + } catch (error) { + return rejectWithValue(error.response?.data || 'Failed to create knowledge base'); + } + } +); + +/** + * Get knowledge base details by ID + */ +export const getKnowledgeBaseById = createAsyncThunk( + 'knowledgeBase/getKnowledgeBaseById', + async (id, { rejectWithValue }) => { + try { + const response = await get(`/knowledge-bases/${id}/`); + // 处理新的返回格式 + if (response.data && response.data.code === 200) { + return response.data.data; + } + return response.data; + } catch (error) { + return rejectWithValue(error.response?.data || 'Failed to get knowledge base details'); + } + } +); + +/** + * Update knowledge base + * @param {Object} params - Update parameters + * @param {string} params.id - Knowledge base ID + * @param {Object} params.data - Update data (name, desc) + */ +export const updateKnowledgeBase = createAsyncThunk( + 'knowledgeBase/updateKnowledgeBase', + async ({ id, data }, { rejectWithValue }) => { + try { + const response = await put(`/knowledge-bases/${id}/`, data); + + // 处理新的返回格式 + if (response.data && response.data.code === 200) { + return response.data.data.knowledge_base; + } + + return response.data; + } catch (error) { + return rejectWithValue(error.response?.data || 'Failed to update knowledge base'); + } + } +); + +/** + * Delete knowledge base + * @param {string} id - Knowledge base ID + */ +export const deleteKnowledgeBase = createAsyncThunk( + 'knowledgeBase/deleteKnowledgeBase', + async (id, { rejectWithValue }) => { + try { + await del(`/knowledge-bases/${id}/`); + return id; + } catch (error) { + return rejectWithValue(error.response?.data || 'Failed to delete knowledge base'); + } + } +); + +/** + * Change knowledge base type + * @param {Object} params - Parameters + * @param {string} params.id - Knowledge base ID + * @param {string} params.type - New knowledge base type + * @param {string} params.department - User department + * @param {string} params.group - User group + */ +export const changeKnowledgeBaseType = createAsyncThunk( + 'knowledgeBase/changeType', + async ({ id, type, department, group }, { rejectWithValue }) => { + try { + const response = await post(`/knowledge-bases/${id}/change_type/`, { + type, + department, + group, + }); + + // 处理新的返回格式 + if (response.data && response.data.code === 200) { + return response.data.data; + } + + return response.data; + } catch (error) { + return rejectWithValue(error.response?.data || '修改知识库类型失败'); + } + } +); + +/** + * 申请知识库访问权限 + * @param {Object} params - 参数 + * @param {string} params.knowledge_base - 知识库ID + * @param {Object} params.permissions - 权限对象 + * @param {string} params.reason - 申请原因 + * @param {string} params.expires_at - 过期时间 + * @returns {Promise} - Promise对象 + */ +export const requestKnowledgeBaseAccess = createAsyncThunk( + 'knowledgeBase/requestAccess', + async (params, { rejectWithValue, dispatch }) => { + try { + const response = await post('/permissions/', params); + dispatch( + showNotification({ + type: 'success', + message: '权限申请已发送,请等待管理员审核', + }) + ); + return response.data; + } catch (error) { + dispatch( + showNotification({ + type: 'danger', + message: error.response?.data?.detail || '权限申请失败,请稍后重试', + }) + ); + return rejectWithValue(error.response?.data || error.message); + } + } +); diff --git a/src/store/notification.slice.js b/src/store/notification.slice.js index 5e49057..b0658cd 100644 --- a/src/store/notification.slice.js +++ b/src/store/notification.slice.js @@ -2,7 +2,7 @@ import { createSlice } from '@reduxjs/toolkit'; const notificationSlice = createSlice({ name: 'notification', - initialState: null, + initialState: null, // type(success/primary/warning/danger), message, duration reducers: { showNotification: (state, action) => action.payload, hideNotification: () => null, diff --git a/src/store/notificationCenter/notificationCenter.slice.js b/src/store/notificationCenter/notificationCenter.slice.js new file mode 100644 index 0000000..9ccce4b --- /dev/null +++ b/src/store/notificationCenter/notificationCenter.slice.js @@ -0,0 +1,123 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + notifications: [ + { + id: 1, + type: 'permission', + icon: 'bi-shield', + title: '新的权限请求', + content: '张三请求访问销售数据集', + time: '10分钟前', + hasDetail: true, + isRead: false, + }, + { + id: 2, + type: 'system', + icon: 'bi-info-circle', + title: '系统更新通知', + content: '系统将在今晚23:00进行例行维护', + time: '1小时前', + hasDetail: false, + isRead: false, + }, + { + id: 3, + type: 'permission', + icon: 'bi-shield', + title: '新的权限请求', + content: '李四请求访问用户数据集', + time: '2小时前', + hasDetail: true, + isRead: false, + }, + { + id: 4, + type: 'system', + icon: 'bi-exclamation-circle', + title: '安全提醒', + content: '检测到异常登录行为,请及时查看', + time: '3小时前', + hasDetail: true, + isRead: false, + }, + { + id: 5, + type: 'permission', + icon: 'bi-shield', + title: '权限变更通知', + content: '管理员修改了您的数据访问权限', + time: '1天前', + hasDetail: true, + isRead: false, + }, + ], + unreadCount: 5, + isConnected: false, +}; + +const notificationCenterSlice = createSlice({ + name: 'notificationCenter', + initialState, + reducers: { + clearNotifications: (state) => { + state.notifications = []; + state.unreadCount = 0; + }, + + addNotification: (state, action) => { + // 检查通知是否已存在 + const exists = state.notifications.some((n) => n.id === action.payload.id); + if (!exists) { + // 将新通知添加到列表的开头 + state.notifications.unshift(action.payload); + // 如果通知未读,增加未读计数 + if (!action.payload.isRead) { + state.unreadCount += 1; + } + } + }, + + markNotificationAsRead: (state, action) => { + const notification = state.notifications.find((n) => n.id === action.payload); + if (notification && !notification.isRead) { + notification.isRead = true; + state.unreadCount = Math.max(0, state.unreadCount - 1); + } + }, + + markAllNotificationsAsRead: (state) => { + state.notifications.forEach((notification) => { + notification.isRead = true; + }); + state.unreadCount = 0; + }, + + setWebSocketConnected: (state, action) => { + state.isConnected = action.payload; + }, + + removeNotification: (state, action) => { + const notificationIndex = state.notifications.findIndex((n) => n.id === action.payload); + if (notificationIndex !== -1) { + const notification = state.notifications[notificationIndex]; + if (!notification.isRead) { + state.unreadCount = Math.max(0, state.unreadCount - 1); + } + state.notifications.splice(notificationIndex, 1); + } + }, + }, +}); + +export const { + clearNotifications, + addNotification, + markNotificationAsRead, + markAllNotificationsAsRead, + setWebSocketConnected, + removeNotification, +} = notificationCenterSlice.actions; + +export default notificationCenterSlice.reducer; diff --git a/src/store/permissions/permissions.slice.js b/src/store/permissions/permissions.slice.js new file mode 100644 index 0000000..5a2a371 --- /dev/null +++ b/src/store/permissions/permissions.slice.js @@ -0,0 +1,159 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { + fetchUserPermissions, + updateUserPermissions, + fetchPermissionsThunk, + approvePermissionThunk, + rejectPermissionThunk, + fetchAllUserPermissions, +} from './permissions.thunks'; + +const initialState = { + users: { + items: [], + status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' + error: null, + }, + allUsersPermissions: { + results: [], + total: 0, + page: 1, + page_size: 10, + status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' + error: null, + }, + operations: { + status: 'idle', + error: null, + }, + pending: { + results: [], // 更改为results + total: 0, // 添加total + page: 1, // 添加page + page_size: 10, // 添加page_size + status: 'idle', + error: null, + }, + approveReject: { + status: 'idle', + error: null, + currentId: null, + }, +}; + +const permissionsSlice = createSlice({ + name: 'permissions', + initialState, + reducers: { + resetOperationStatus: (state) => { + state.operations.status = 'idle'; + state.operations.error = null; + }, + }, + extraReducers: (builder) => { + builder + // 获取用户权限列表 + .addCase(fetchUserPermissions.pending, (state) => { + state.users.status = 'loading'; + state.users.error = null; + }) + .addCase(fetchUserPermissions.fulfilled, (state, action) => { + state.users.status = 'succeeded'; + state.users.items = action.payload; + }) + .addCase(fetchUserPermissions.rejected, (state, action) => { + state.users.status = 'failed'; + state.users.error = action.error.message; + }) + + // 更新用户权限 + .addCase(updateUserPermissions.pending, (state) => { + state.operations.status = 'loading'; + state.operations.error = null; + }) + .addCase(updateUserPermissions.fulfilled, (state, action) => { + state.operations.status = 'succeeded'; + // 更新用户列表中的权限信息 + const index = state.users.items.findIndex((user) => user.id === action.payload.userId); + if (index !== -1) { + state.users.items[index] = { + ...state.users.items[index], + permissions: action.payload.permissions, + }; + } + }) + .addCase(updateUserPermissions.rejected, (state, action) => { + state.operations.status = 'failed'; + state.operations.error = action.error.message; + }) + + // 获取待处理申请列表 + .addCase(fetchPermissionsThunk.pending, (state) => { + state.pending.status = 'loading'; + state.pending.error = null; + }) + .addCase(fetchPermissionsThunk.fulfilled, (state, action) => { + state.pending.status = 'succeeded'; + state.pending.results = action.payload.results || []; + state.pending.total = action.payload.total || 0; + state.pending.page = action.payload.page || 1; + state.pending.page_size = action.payload.page_size || 10; + }) + .addCase(fetchPermissionsThunk.rejected, (state, action) => { + state.pending.status = 'failed'; + state.pending.error = action.error.message; + }) + + // 批准/拒绝权限申请 + .addCase(approvePermissionThunk.pending, (state, action) => { + state.approveReject.status = 'loading'; + state.approveReject.error = null; + state.approveReject.currentId = action.meta.arg.id; + }) + .addCase(approvePermissionThunk.fulfilled, (state) => { + state.approveReject.status = 'succeeded'; + state.approveReject.currentId = null; + }) + .addCase(approvePermissionThunk.rejected, (state, action) => { + state.approveReject.status = 'failed'; + state.approveReject.error = action.error.message; + state.approveReject.currentId = null; + }) + + .addCase(rejectPermissionThunk.pending, (state, action) => { + state.approveReject.status = 'loading'; + state.approveReject.error = null; + state.approveReject.currentId = action.meta.arg.id; + }) + .addCase(rejectPermissionThunk.fulfilled, (state) => { + state.approveReject.status = 'succeeded'; + state.approveReject.currentId = null; + }) + .addCase(rejectPermissionThunk.rejected, (state, action) => { + state.approveReject.status = 'failed'; + state.approveReject.error = action.error.message; + state.approveReject.currentId = null; + }) + + // 获取所有用户及其权限列表 + .addCase(fetchAllUserPermissions.pending, (state) => { + state.allUsersPermissions.status = 'loading'; + state.allUsersPermissions.error = null; + }) + .addCase(fetchAllUserPermissions.fulfilled, (state, action) => { + state.allUsersPermissions.status = 'succeeded'; + state.allUsersPermissions.results = action.payload.results || []; + state.allUsersPermissions.total = action.payload.total || 0; + state.allUsersPermissions.page = action.payload.page || 1; + state.allUsersPermissions.page_size = action.payload.page_size || 10; + }) + .addCase(fetchAllUserPermissions.rejected, (state, action) => { + state.allUsersPermissions.status = 'failed'; + state.allUsersPermissions.error = action.payload || action.error.message; + }); + }, +}); + +export const { resetOperationStatus } = permissionsSlice.actions; + +export default permissionsSlice.reducer; diff --git a/src/store/permissions/permissions.thunks.js b/src/store/permissions/permissions.thunks.js new file mode 100644 index 0000000..67c4d0d --- /dev/null +++ b/src/store/permissions/permissions.thunks.js @@ -0,0 +1,167 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { get, post, put } from '../../services/api'; +import { showNotification } from '../notification.slice'; + +// 获取权限申请列表 +export const fetchPermissionsThunk = createAsyncThunk( + 'permissions/fetchPermissions', + async (params = {}, { rejectWithValue }) => { + try { + const response = await get('/permissions/', { params }); + + if (response && response.code === 200) { + return { + results: response.data.results || [], + total: response.data.total || 0, + page: response.data.page || 1, + page_size: response.data.page_size || 10, + }; + } + return rejectWithValue('获取权限申请列表失败'); + } catch (error) { + console.error('获取权限申请列表失败:', error); + return rejectWithValue(error.response?.data?.message || '获取权限申请列表失败'); + } + } +); + +// 批准权限申请 +export const approvePermissionThunk = createAsyncThunk( + 'permissions/approvePermission', + async ({ id, responseMessage }, { rejectWithValue }) => { + try { + const response = await post(`/permissions/${id}/approve/`, { + response_message: responseMessage || '已批准', + }); + return response; + } catch (error) { + console.error('批准权限申请失败:', error); + return rejectWithValue('批准权限申请失败'); + } + } +); + +// 拒绝权限申请 +export const rejectPermissionThunk = createAsyncThunk( + 'permissions/rejectPermission', + async ({ id, responseMessage }, { rejectWithValue }) => { + try { + const response = await post(`/permissions/${id}/reject/`, { + response_message: responseMessage || '已拒绝', + }); + return response; + } catch (error) { + console.error('拒绝权限申请失败:', error); + return rejectWithValue('拒绝权限申请失败'); + } + } +); + +// 生成模拟数据 +const generateMockUsers = () => { + const users = []; + const userNames = [ + { username: 'zhangsan', name: '张三', department: '达人组', position: '达人对接' }, + { username: 'lisi', name: '李四', department: '达人组', position: '达人对接' }, + { username: 'wangwu', name: '王五', department: '达人组', position: '达人对接' }, + { username: 'zhaoliu', name: '赵六', department: '达人组', position: '达人对接' }, + { username: 'qianqi', name: '钱七', department: '达人组', position: '达人对接' }, + { username: 'sunba', name: '孙八', department: '达人组', position: '达人对接' }, + { username: 'zhoujiu', name: '周九', department: '达人组', position: '达人对接' }, + { username: 'wushi', name: '吴十', department: '达人组', position: '达人对接' }, + ]; + + for (let i = 1; i <= 20; i++) { + const randomUser = userNames[Math.floor(Math.random() * userNames.length)]; + const hasAdminPermission = Math.random() > 0.8; // 20%的概率有管理员权限 + const hasEditPermission = Math.random() > 0.5; // 50%的概率有编辑权限 + const hasReadPermission = hasAdminPermission || hasEditPermission || Math.random() > 0.3; // 如果有管理员或编辑权限,一定有读取权限 + + users.push({ + id: i.toString(), + username: randomUser.username, + name: randomUser.name, + department: randomUser.department, + position: randomUser.position, + permissions_count: { + read: hasReadPermission ? 1 : 0, + edit: hasEditPermission ? 1 : 0, + admin: hasAdminPermission ? 1 : 0, + }, + }); + } + + return users; +}; + +// 获取用户权限列表 +export const fetchUserPermissions = createAsyncThunk( + 'permissions/fetchUserPermissions', + async (_, { rejectWithValue }) => { + try { + // 模拟API延迟 + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // 返回模拟数据 + return generateMockUsers(); + } catch (error) { + return rejectWithValue(error.message); + } + } +); + +// 更新用户权限 +export const updateUserPermissions = createAsyncThunk( + 'permissions/updateUserPermissions', + async ({ userId, permissions }, { dispatch, rejectWithValue }) => { + try { + const response = await put(`/users/${userId}/permissions/`, { permissions }); + + if (response && response.code === 200) { + dispatch( + showNotification({ + message: '权限更新成功', + type: 'success', + }) + ); + return { + userId, + permissions: response.data.permissions, + }; + } + return rejectWithValue(response?.message || '更新权限失败'); + } catch (error) { + dispatch( + showNotification({ + message: error.message || '更新权限失败', + type: 'danger', + }) + ); + return rejectWithValue(error.message); + } + } +); + +// 获取所有用户及其权限列表 +export const fetchAllUserPermissions = createAsyncThunk( + 'permissions/fetchAllUserPermissions', + async (params = {}, { rejectWithValue }) => { + try { + const response = await get('/permissions/all_permissions/', { params }); + + if (response && response.code === 200) { + return { + total: response.data.total, + page: response.data.page, + page_size: response.data.page_size, + results: response.data.results, + }; + } + + return rejectWithValue('获取用户权限列表失败'); + } catch (error) { + console.error('获取用户权限列表失败:', error); + return rejectWithValue(error.response?.data?.message || '获取用户权限列表失败'); + } + } +); diff --git a/src/store/store.js b/src/store/store.js index 3b4dbfc..8cdecb4 100644 --- a/src/store/store.js +++ b/src/store/store.js @@ -2,15 +2,25 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit'; import { persistReducer, persistStore } from 'redux-persist'; import sessionStorage from 'redux-persist/lib/storage/session'; import notificationReducer from './notification.slice.js'; +import authReducer from './auth/auth.slice.js'; +import knowledgeBaseReducer from './knowledgeBase/knowledgeBase.slice.js'; +import chatReducer from './chat/chat.slice.js'; +import permissionsReducer from './permissions/permissions.slice.js'; +import notificationCenterReducer from './notificationCenter/notificationCenter.slice.js'; const rootRducer = combineReducers({ + auth: authReducer, notification: notificationReducer, + knowledgeBase: knowledgeBaseReducer, + chat: chatReducer, + permissions: permissionsReducer, + notificationCenter: notificationCenterReducer, }); const persistConfig = { key: 'root', storage: sessionStorage, - whitelist: [], + whitelist: ['auth'], }; // Persist configuration diff --git a/src/styles/style.scss b/src/styles/style.scss index 148177b..aba457a 100644 --- a/src/styles/style.scss +++ b/src/styles/style.scss @@ -1,5 +1,8 @@ @import 'bootstrap/scss/bootstrap'; +#root { + min-width: 24rem; +} .dropdown-toggle { outline: 0; } @@ -9,6 +12,9 @@ } .knowledge-card { + min-width: 20rem; + cursor: pointer; + .hoverdown:hover .hoverdown-menu{ display: block; color: red; @@ -27,6 +33,7 @@ gap: 8px; border-radius: 4px; color: $dark; + &:hover { background-color: $gray-100; } @@ -34,3 +41,106 @@ } } } + +.auth-form { + input { + min-width: 300px !important; + } +} + +/* 自定义黑色系开关样式 */ +.dark-switch .form-check-input { + border: 1px solid #dee2e6; + background-color: #fff; /* 关闭状态背景色 */ +} + +/* 关闭状态滑块 */ +.dark-switch .form-check-input:not(:checked) { + background-image: url("data:image/svg+xml,"); +} + +/* 打开状态 */ +.dark-switch .form-check-input:checked { + background-color: #000; /* 打开状态背景色 */ + border-color: #000; +} + +/* 打开状态滑块 */ +.dark-switch .form-check-input:checked { + background-image: url("data:image/svg+xml,"); +} + +/* 悬停效果 */ +.dark-switch .form-check-input:hover { + filter: brightness(0.9); +} + +/* 禁用状态 */ +.dark-switch .form-check-input:disabled { + opacity: 0.5; + background-color: #e9ecef; +} + +// 通知中心样式 +.notification-item { + transition: background-color 0.2s ease; + + &:hover { + background-color: $gray-100; + } +} + +// 黑色主题的开关按钮 +.form-check-input:checked { + background-color: $dark; + border-color: $dark; +} + +/* 自定义分页样式 */ +.dark-pagination { + margin: 0; +} + +.dark-pagination .page-link { + color: #000; /* 默认文字颜色 */ + background-color: #fff; /* 默认背景 */ + border: 1px solid #dee2e6; /* 边框颜色 */ + transition: all 0.3s ease; /* 平滑过渡效果 */ + } + + /* 激活状态 */ + .dark-pagination .page-item.active .page-link { + background-color: #000 !important; + border-color: #000; + color: #fff !important; + } + + /* 悬停状态 */ + .dark-pagination .page-link:hover { + background-color: #f8f9fa; /* 浅灰背景 */ + border-color: #adb5bd; + } + + /* 禁用状态 */ + .dark-pagination .page-item.disabled .page-link { + color: #6c757d !important; + background-color: #e9ecef !important; + border-color: #dee2e6; + pointer-events: none; + opacity: 0.7; + } + + /* 自定义下拉框 */ + .dark-select { + border: 1px solid #000 !important; + color: #000 !important; + } + + .dark-select:focus { + box-shadow: 0 0 0 0.25rem rgba(0, 0, 0, 0.25); /* 黑色聚焦阴影 */ + } + + /* 下拉箭头颜色 */ + .dark-select { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='%23000' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); + } \ No newline at end of file diff --git a/vite.config.js b/vite.config.js index 90cdbfd..f2ecfab 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,13 +1,23 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig, loadEnv } from 'vite'; +import react from '@vitejs/plugin-react'; // https://vite.dev/config/ -export default defineConfig({ - plugins: [react()], - build: { - outDir: '../dist' - }, - server: { - port: 8080 - } -}) +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd()); + + return { + plugins: [react()], + build: { + outDir: '../dist', + }, + server: { + port: env.VITE_PORT, + proxy: { + '/api': { + target: env.VITE_API_URL || 'http://81.69.223.133:3000', + changeOrigin: true, + }, + }, + }, + }; +});