From 0ab3d4f9c13a42faa7960a51e6959738bec83098 Mon Sep 17 00:00:00 2001 From: xiaji Date: Fri, 30 Jan 2026 12:03:12 +0800 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20=E7=9F=A5=E8=AF=86=E5=BA=93?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=99=A8=E5=BA=94=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现基于 PySide6 的 GUI 界面 - 集成 FastAPI 知识库服务器 API - 支持查看、编辑、提交 Markdown 文件 - 包含完整的 pytest-qt 测试套件 - 添加功能列表文档 --- FEATURES.md | 80 ++++++++++++++++++ README.md | 96 ++++++++++++++++++++++ api_client.py | 43 ++++++++++ fastapi的内容.txt | 2 + main_window.py | 203 ++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 4 + run.bat | 43 ++++++++++ run_tests.bat | 43 ++++++++++ test_app.py | 180 ++++++++++++++++++++++++++++++++++++++++ 地址.txt | 1 + 10 files changed, 695 insertions(+) create mode 100644 FEATURES.md create mode 100644 README.md create mode 100644 api_client.py create mode 100644 fastapi的内容.txt create mode 100644 main_window.py create mode 100644 requirements.txt create mode 100644 run.bat create mode 100644 run_tests.bat create mode 100644 test_app.py create mode 100644 地址.txt diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 0000000..ec81a38 --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,80 @@ +# 功能列表 + +## 核心功能 + +### 1. 服务器管理 +- ✅ 服务器地址配置 +- ✅ 连接测试 +- ✅ 状态显示 +- ✅ 默认地址预设(http://43.134.1.17:8800/) + +### 2. 文件管理 +- ✅ 获取服务器文件列表 +- ✅ 自动过滤只显示 .md 文件 +- ✅ 刷新文件列表 +- ✅ 文件列表点击加载 + +### 3. 文件编辑 +- ✅ 查看 Markdown 文件内容 +- ✅ 在线编辑文件内容 +- ✅ 文件名显示和编辑 +- ✅ 自动添加 .md 扩展名 + +### 4. 文件操作 +- ✅ 保存修改到服务器(POST 请求) +- ✅ 提交新文件到服务器 +- ✅ 文件内容验证 +- ✅ 操作成功/失败提示 + +### 5. 用户界面 +- ✅ 直观的分割窗口布局 +- ✅ 左侧文件列表 +- ✅ 右侧编辑区域 +- ✅ 顶部服务器配置栏 +- ✅ 底部操作按钮 +- ✅ 状态栏显示 +- ✅ 错误提示对话框 +- ✅ 成功提示对话框 + +## 技术特性 + +### API 集成 +- ✅ GET /list - 获取文件列表 +- ✅ GET /{filename}/get - 下载文件内容 +- ✅ POST /{filename}/post - 上传文件内容 +- ✅ 请求超时处理(10秒) +- ✅ 错误处理和异常捕获 + +### 测试覆盖 +- ✅ 窗口初始化测试 +- ✅ UI 组件测试 +- ✅ 服务器配置测试 +- ✅ 文件操作测试 +- ✅ 边界条件测试 +- ✅ Mock 服务器响应测试 + +### 用户体验 +- ✅ 自动依赖检查 +- ✅ 一键启动脚本 +- ✅ 一键测试脚本 +- ✅ 中文本地化 +- ✅ 友好的错误提示 +- ✅ 实时状态更新 + +## 依赖项 + +- PySide6 6.6.0 - Qt6 GUI 框架 +- requests 2.31.0 - HTTP 客户端 +- pytest 7.4.3 - 测试框架 +- pytest-qt 4.2.0 - Qt 应用测试 + +## 项目文件 + +- main_window.py - 主应用程序 +- api_client.py - API 客户端 +- test_app.py - 测试套件 +- requirements.txt - 依赖列表 +- run.bat - 启动脚本 +- run_tests.bat - 测试脚本 +- README.md - 使用文档 +- FEATURES.md - 功能列表(本文件) \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..271802e --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# 知识库管理器 + +基于 PySide6 的知识库管理应用程序,用于连接 FastAPI 知识库服务器,查看、编辑和提交 Markdown 格式的知识库文件。 + +## 功能特性 + +- 服务器地址配置和连接测试 +- 获取服务器上的文件列表(仅显示 .md 文件) +- 查看和编辑 Markdown 文件内容 +- 保存修改到服务器 +- 提交新的知识库文件 +- 直观的用户界面,支持分割窗口 + +## 安装依赖 + +```bash +pip install -r requirements.txt +``` + +## 运行应用程序 + +### Windows 用户 + +双击 `run.bat` 文件启动应用程序。 + +### 命令行运行 + +```bash +python main_window.py +``` + +## 运行测试 + +### Windows 用户 + +双击 `run_tests.bat` 文件运行测试。 + +### 命令行运行 + +```bash +pytest test_app.py -v +``` + +## API 端点 + +应用程序使用以下 API 端点: + +- `GET /list` - 获取文件列表 +- `GET /{filename}/get` - 下载文件内容 +- `POST /{filename}/post` - 上传文件内容 + +## 使用说明 + +1. **配置服务器**: + - 在"服务器地址"输入框中输入服务器地址(例如:`http://43.134.1.17:8800/`) + - 点击"配置"按钮连接服务器 + +2. **查看文件列表**: + - 配置成功后,点击"刷新列表"按钮 + - 文件列表将显示所有 .md 文件 + +3. **编辑文件**: + - 在左侧文件列表中点击要编辑的文件 + - 文件内容将显示在右侧编辑器中 + - 编辑完成后点击"保存到服务器" + +4. **提交新文件**: + - 在"文件名"输入框中输入新文件名(自动添加 .md 扩展名) + - 在编辑器中输入文件内容 + - 点击"提交新文件"按钮 + +## 文件结构 + +``` +. +├── main_window.py # 主应用程序窗口 +├── api_client.py # API 客户端模块 +├── test_app.py # pytest-qt 测试用例 +├── requirements.txt # Python 依赖 +├── run.bat # Windows 启动脚本 +├── run_tests.bat # Windows 测试脚本 +└── README.md # 本文件 +``` + +## 技术栈 + +- **UI 框架**: PySide6 (Qt6 的官方 Python 绑定) +- **HTTP 客户端**: requests +- **测试框架**: pytest, pytest-qt + +## 注意事项 + +- 确保服务器地址正确且可访问 +- 文件名会自动添加 .md 扩展名 +- 网络操作有超时设置(10秒) +- 所有文件操作都有错误提示 \ No newline at end of file diff --git a/api_client.py b/api_client.py new file mode 100644 index 0000000..2052f30 --- /dev/null +++ b/api_client.py @@ -0,0 +1,43 @@ +import requests +from typing import List, Optional + + +class KnowledgeBaseAPI: + def __init__(self, base_url: str): + self.base_url = base_url.rstrip('/') + + def get_file_list(self) -> List[str]: + """获取服务器上的文件列表""" + try: + response = requests.get(f"{self.base_url}/list", timeout=10) + response.raise_for_status() + return response.json() + except requests.RequestException as e: + raise Exception(f"获取文件列表失败: {str(e)}") + + def get_file(self, filename: str) -> str: + """获取文件内容""" + try: + response = requests.get(f"{self.base_url}/{filename}/get", timeout=10) + response.raise_for_status() + return response.text + except requests.RequestException as e: + raise Exception(f"获取文件 {filename} 失败: {str(e)}") + + def post_file(self, filename: str, content: str) -> bool: + """上传文件内容""" + try: + files = {'file': (filename, content, 'text/markdown')} + response = requests.post(f"{self.base_url}/{filename}/post", files=files, timeout=10) + response.raise_for_status() + return True + except requests.RequestException as e: + raise Exception(f"上传文件 {filename} 失败: {str(e)}") + + def test_connection(self) -> bool: + """测试服务器连接""" + try: + response = requests.get(f"{self.base_url}/list", timeout=5) + return response.status_code == 200 + except requests.RequestException: + return False \ No newline at end of file diff --git a/fastapi的内容.txt b/fastapi的内容.txt new file mode 100644 index 0000000..24c2631 --- /dev/null +++ b/fastapi的内容.txt @@ -0,0 +1,2 @@ +{"service":"知识库API服务","version":"1.0.0","endpoints":[{"method":"GET","path":"/list","description":"获取文件列表"},{"method":"GET","path":"/{filename}/get","description":"下载文件"},{"method":"POST","path":"/{filename}/post","description":"上传文件"}],"knowledge_base_path":"/root/clawd/knowledge_base","port":8800,"usage_examples":{"list_files":"curl -X GET http://[服务器IP]:8800/list","download_file":"curl -X GET http://[服务器IP]:8800/example.txt/get -o example.txt","upload_file":"curl -X POST -F 'file=@local_file.txt' http://[服务器IP]:8800/remote_name.txt/post"}} + diff --git a/main_window.py b/main_window.py new file mode 100644 index 0000000..3ec44dc --- /dev/null +++ b/main_window.py @@ -0,0 +1,203 @@ +import sys +from PySide6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QLineEdit, QPushButton, QListWidget, QTextEdit, + QMessageBox, QSplitter, QFileDialog +) +from PySide6.QtCore import Qt +from api_client import KnowledgeBaseAPI + + +class KnowledgeBaseApp(QMainWindow): + def __init__(self): + super().__init__() + self.api: Optional[KnowledgeBaseAPI] = None + self.current_filename: Optional[str] = None + self.init_ui() + + def init_ui(self): + self.setWindowTitle('知识库管理器') + self.setGeometry(100, 100, 1200, 800) + + central_widget = QWidget() + self.setCentralWidget(central_widget) + + main_layout = QVBoxLayout(central_widget) + + # 服务器地址配置区域 + server_layout = QHBoxLayout() + server_label = QLabel('服务器地址:') + self.server_input = QLineEdit('http://43.134.1.17:8800/') + self.server_input.setMinimumWidth(300) + self.config_button = QPushButton('配置') + self.config_button.clicked.connect(self.configure_server) + self.refresh_button = QPushButton('刷新列表') + self.refresh_button.clicked.connect(self.refresh_file_list) + + server_layout.addWidget(server_label) + server_layout.addWidget(self.server_input) + server_layout.addWidget(self.config_button) + server_layout.addWidget(self.refresh_button) + server_layout.addStretch() + + main_layout.addLayout(server_layout) + + # 主内容区域(使用分割器) + splitter = QSplitter(Qt.Horizontal) + + # 左侧:文件列表 + left_widget = QWidget() + left_layout = QVBoxLayout(left_widget) + + list_label = QLabel('文件列表 (.md):') + self.file_list = QListWidget() + self.file_list.itemClicked.connect(self.load_file) + + left_layout.addWidget(list_label) + left_layout.addWidget(self.file_list) + + # 右侧:编辑区域 + right_widget = QWidget() + right_layout = QVBoxLayout(right_widget) + + filename_layout = QHBoxLayout() + filename_label = QLabel('文件名:') + self.filename_input = QLineEdit() + self.filename_input.setPlaceholderText('输入文件名(例如:example.md)') + + filename_layout.addWidget(filename_label) + filename_layout.addWidget(self.filename_input) + + self.text_editor = QTextEdit() + self.text_editor.setPlaceholderText('在此编辑 Markdown 内容...') + + button_layout = QHBoxLayout() + self.save_button = QPushButton('保存到服务器') + self.save_button.clicked.connect(self.save_file) + self.post_button = QPushButton('提交新文件') + self.post_button.clicked.connect(self.post_new_file) + + button_layout.addWidget(self.save_button) + button_layout.addWidget(self.post_button) + + right_layout.addLayout(filename_layout) + right_layout.addWidget(self.text_editor) + right_layout.addLayout(button_layout) + + splitter.addWidget(left_widget) + splitter.addWidget(right_widget) + splitter.setSizes([300, 900]) + + main_layout.addWidget(splitter) + + # 状态栏 + self.status_label = QLabel('就绪') + self.statusBar().addWidget(self.status_label) + + def configure_server(self): + server_url = self.server_input.text().strip() + if not server_url: + QMessageBox.warning(self, '警告', '请输入服务器地址') + return + + self.api = KnowledgeBaseAPI(server_url) + + if self.api.test_connection(): + self.status_label.setText(f'已连接到服务器: {server_url}') + QMessageBox.information(self, '成功', '服务器配置成功!') + self.refresh_file_list() + else: + QMessageBox.critical(self, '错误', '无法连接到服务器,请检查地址是否正确') + self.status_label.setText('连接失败') + + def refresh_file_list(self): + if not self.api: + QMessageBox.warning(self, '警告', '请先配置服务器') + return + + try: + files = self.api.get_file_list() + self.file_list.clear() + + # 只显示 .md 文件 + md_files = [f for f in files if f.endswith('.md')] + self.file_list.addItems(md_files) + + self.status_label.setText(f'已加载 {len(md_files)} 个文件') + except Exception as e: + QMessageBox.critical(self, '错误', str(e)) + self.status_label.setText('加载文件列表失败') + + def load_file(self, item): + filename = item.text() + self.current_filename = filename + self.filename_input.setText(filename) + + try: + content = self.api.get_file(filename) + self.text_editor.setPlainText(content) + self.status_label.setText(f'已加载文件: {filename}') + except Exception as e: + QMessageBox.critical(self, '错误', str(e)) + self.status_label.setText('加载文件失败') + + def save_file(self): + if not self.api: + QMessageBox.warning(self, '警告', '请先配置服务器') + return + + if not self.current_filename: + QMessageBox.warning(self, '警告', '请先选择一个文件') + return + + content = self.text_editor.toPlainText() + + try: + self.api.post_file(self.current_filename, content) + QMessageBox.information(self, '成功', f'文件 {self.current_filename} 保存成功!') + self.status_label.setText(f'已保存文件: {self.current_filename}') + except Exception as e: + QMessageBox.critical(self, '错误', str(e)) + self.status_label.setText('保存文件失败') + + def post_new_file(self): + if not self.api: + QMessageBox.warning(self, '警告', '请先配置服务器') + return + + filename = self.filename_input.text().strip() + + if not filename: + QMessageBox.warning(self, '警告', '请输入文件名') + return + + if not filename.endswith('.md'): + filename += '.md' + self.filename_input.setText(filename) + + content = self.text_editor.toPlainText() + + if not content: + QMessageBox.warning(self, '警告', '请输入文件内容') + return + + try: + self.api.post_file(filename, content) + QMessageBox.information(self, '成功', f'文件 {filename} 提交成功!') + self.current_filename = filename + self.status_label.setText(f'已提交文件: {filename}') + self.refresh_file_list() + except Exception as e: + QMessageBox.critical(self, '错误', str(e)) + self.status_label.setText('提交文件失败') + + +def main(): + app = QApplication(sys.argv) + window = KnowledgeBaseApp() + window.show() + sys.exit(app.exec()) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..24219be --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +PySide6==6.6.0 +requests==2.31.0 +pytest-qt==4.2.0 +pytest==7.4.3 \ No newline at end of file diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..30eeaec --- /dev/null +++ b/run.bat @@ -0,0 +1,43 @@ +@echo off +chcp 65001 > nul +echo ================================================ +echo 知识库管理器 - 启动脚本 +echo ================================================ +echo. + +REM 检查 Python 是否安装 +python --version >nul 2>&1 +if %errorlevel% neq 0 ( + echo [错误] 未找到 Python,请先安装 Python 3.8 或更高版本 + pause + exit /b 1 +) + +echo [1/3] 检查依赖... +pip show PySide6 >nul 2>&1 +if %errorlevel% neq 0 ( + echo [提示] 未安装依赖,正在安装... + pip install -r requirements.txt + if %errorlevel% neq 0 ( + echo [错误] 依赖安装失败 + pause + exit /b 1 + ) +) else ( + echo [成功] 依赖已安装 +) + +echo. +echo [2/3] 启动应用程序... +python main_window.py + +if %errorlevel% neq 0 ( + echo. + echo [错误] 应用程序启动失败 + pause + exit /b 1 +) + +echo. +echo [成功] 应用程序已关闭 +pause \ No newline at end of file diff --git a/run_tests.bat b/run_tests.bat new file mode 100644 index 0000000..a23d7c8 --- /dev/null +++ b/run_tests.bat @@ -0,0 +1,43 @@ +@echo off +chcp 65001 > nul +echo ================================================ +echo 知识库管理器 - 测试脚本 +echo ================================================ +echo. + +REM 检查 Python 是否安装 +python --version >nul 2>&1 +if %errorlevel% neq 0 ( + echo [错误] 未找到 Python,请先安装 Python 3.8 或更高版本 + pause + exit /b 1 +) + +echo [1/3] 检查依赖... +pip show pytest-qt >nul 2>&1 +if %errorlevel% neq 0 ( + echo [提示] 未安装依赖,正在安装... + pip install -r requirements.txt + if %errorlevel% neq 0 ( + echo [错误] 依赖安装失败 + pause + exit /b 1 + ) +) else ( + echo [成功] 依赖已安装 +) + +echo. +echo [2/3] 运行测试... +pytest test_app.py -v + +if %errorlevel% neq 0 ( + echo. + echo [警告] 部分测试失败 +) else ( + echo. + echo [成功] 所有测试通过 +) + +echo. +pause \ No newline at end of file diff --git a/test_app.py b/test_app.py new file mode 100644 index 0000000..260e7c1 --- /dev/null +++ b/test_app.py @@ -0,0 +1,180 @@ +import pytest +from PySide6.QtWidgets import QApplication, QMessageBox +from PySide6.QtCore import Qt +from unittest.mock import Mock, patch +from main_window import KnowledgeBaseApp + + +@pytest.fixture(scope="module") +def app(): + """创建 QApplication 实例""" + if not QApplication.instance(): + app = QApplication([]) + else: + app = QApplication.instance() + yield app + + +@pytest.fixture +def main_window(app, qtbot): + """创建主窗口实例""" + window = KnowledgeBaseApp() + qtbot.addWidget(window) + window.show() + return window + + +def test_window_initialization(main_window, qtbot): + """测试窗口初始化""" + assert main_window.windowTitle() == '知识库管理器' + assert main_window.server_input.text() == 'http://43.134.1.17:8800/' + assert main_window.config_button.text() == '配置' + assert main_window.refresh_button.text() == '刷新列表' + assert main_window.save_button.text() == '保存到服务器' + assert main_window.post_button.text() == '提交新文件' + + +def test_server_input_exists(main_window, qtbot): + """测试服务器地址输入框""" + assert main_window.server_input is not None + assert main_window.server_input.placeholderText() == '' + qtbot.keyClicks(main_window.server_input, 'http://test.com') + assert 'http://test.com' in main_window.server_input.text() + + +def test_filename_input_exists(main_window, qtbot): + """测试文件名输入框""" + assert main_window.filename_input is not None + assert main_window.filename_input.placeholderText() == '输入文件名(例如:example.md)' + qtbot.keyClicks(main_window.filename_input, 'test.md') + assert main_window.filename_input.text() == 'test.md' + + +def test_text_editor_exists(main_window, qtbot): + """测试文本编辑器""" + assert main_window.text_editor is not None + test_content = '# Test Markdown\n\nThis is a test.' + main_window.text_editor.setPlainText(test_content) + assert main_window.text_editor.toPlainText() == test_content + + +def test_file_list_exists(main_window): + """测试文件列表""" + assert main_window.file_list is not None + assert main_window.file_list.count() == 0 + + +def test_buttons_clickable(main_window, qtbot): + """测试按钮可点击""" + assert main_window.config_button.isEnabled() + assert main_window.refresh_button.isEnabled() + assert main_window.save_button.isEnabled() + assert main_window.post_button.isEnabled() + + +def test_configure_server_without_url(main_window, qtbot): + """测试配置服务器(无URL)""" + main_window.server_input.clear() + + with patch.object(QMessageBox, 'warning') as mock_warning: + main_window.configure_server() + mock_warning.assert_called_once() + assert main_window.api is None + + +def test_configure_server_invalid_url(main_window, qtbot): + """测试配置服务器(无效URL)""" + main_window.server_input.setText('http://invalid-url-that-does-not-exist.com') + + with patch.object(QMessageBox, 'critical') as mock_critical: + main_window.configure_server() + mock_critical.assert_called_once() + + +def test_refresh_file_list_without_api(main_window, qtbot): + """测试刷新文件列表(未配置服务器)""" + main_window.api = None + + with patch.object(QMessageBox, 'warning') as mock_warning: + main_window.refresh_file_list() + mock_warning.assert_called_once() + + +def test_save_file_without_api(main_window, qtbot): + """测试保存文件(未配置服务器)""" + main_window.api = None + + with patch.object(QMessageBox, 'warning') as mock_warning: + main_window.save_file() + mock_warning.assert_called_once() + + +def test_save_file_without_selection(main_window, qtbot): + """测试保存文件(未选择文件)""" + main_window.api = Mock() + main_window.current_filename = None + + with patch.object(QMessageBox, 'warning') as mock_warning: + main_window.save_file() + mock_warning.assert_called_once() + + +def test_post_new_file_without_api(main_window, qtbot): + """测试提交新文件(未配置服务器)""" + main_window.api = None + + with patch.object(QMessageBox, 'warning') as mock_warning: + main_window.post_new_file() + mock_warning.assert_called_once() + + +def test_post_new_file_without_filename(main_window, qtbot): + """测试提交新文件(无文件名)""" + main_window.api = Mock() + main_window.filename_input.clear() + + with patch.object(QMessageBox, 'warning') as mock_warning: + main_window.post_new_file() + mock_warning.assert_called_once() + + +def test_post_new_file_without_content(main_window, qtbot): + """测试提交新文件(无内容)""" + main_window.api = Mock() + main_window.filename_input.setText('test.md') + main_window.text_editor.clear() + + with patch.object(QMessageBox, 'warning') as mock_warning: + main_window.post_new_file() + mock_warning.assert_called_once() + + +def test_post_new_file_auto_add_extension(main_window, qtbot): + """测试提交新文件(自动添加.md扩展名)""" + main_window.api = Mock() + main_window.filename_input.setText('test') + main_window.text_editor.setPlainText('# Test') + + with patch.object(QMessageBox, 'information') as mock_info: + with patch.object(main_window, 'refresh_file_list'): + main_window.post_new_file() + assert main_window.filename_input.text() == 'test.md' + + +def test_ui_layout_elements(main_window): + """测试UI布局元素""" + assert main_window.server_input.parent() is not None + assert main_window.file_list.parent() is not None + assert main_window.text_editor.parent() is not None + assert main_window.status_label.parent() is not None + + +def test_status_label_updates(main_window, qtbot): + """测试状态标签更新""" + initial_text = main_window.status_label.text() + main_window.status_label.setText('测试状态') + assert main_window.status_label.text() == '测试状态' + + +if __name__ == '__main__': + pytest.main([__file__, '-v']) \ No newline at end of file diff --git a/地址.txt b/地址.txt new file mode 100644 index 0000000..167ee1e --- /dev/null +++ b/地址.txt @@ -0,0 +1 @@ +http://43.134.1.17:8800/ \ No newline at end of file