截图识别飞书个人任务清单

This commit is contained in:
2026-03-18 15:15:52 +08:00
parent cb5efbe3ca
commit 69f92205e9
23 changed files with 5835 additions and 0 deletions

70
.gitignore vendored Normal file
View File

@@ -0,0 +1,70 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# 虚拟环境
venv/
env/
ENV/
.env
.env.local
# 配置文件(包含敏感信息)
config.yaml
*.secret
*.key
# 日志文件
*.log
logs/
# 测试相关
.pytest_cache/
.coverage
htmlcov/
.tox/
.nox/
.coverage.*
*.lcov
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# 操作系统
.DS_Store
Thumbs.db
# 项目特定
monitor_images/
processed_images/
test_*.py
test_img.jpg
test_img.png
# 临时文件
temp/
tmp/
*.tmp
*.temp

307
Web应用使用说明.md Normal file
View File

@@ -0,0 +1,307 @@
# Screen2Feishu Web应用使用说明
## 概述
Screen2Feishu Web应用是一个结合了前端界面和后端功能的完整解决方案。它提供了一个用户友好的界面可以
1. **手动添加任务**:通过表单添加任务到飞书多维表格
2. **图片上传分析**上传截图AI自动分析并生成任务
3. **实时预览**查看AI分析结果和任务信息
4. **用户匹配**:自动匹配飞书用户,处理重名情况
## 启动应用
### 1. 安装依赖
```bash
pip install flask flask-cors
```
### 2. 配置文件
确保 `config.yaml` 文件已正确配置:
```yaml
ai:
api_key: "your_api_key"
base_url: "https://api.openai.com/v1"
model: "gpt-4o"
feishu:
app_id: "your_app_id"
app_secret: "your_app_secret"
app_token: "your_app_token"
table_id: "your_table_id"
system:
watch_folder: "./monitor_images"
post_process: "keep"
processed_folder: "./processed_images"
log_level: "INFO"
log_file: "app.log"
memory_processing: false
user_matching:
enabled: true
fallback_to_random: true
cache_enabled: true
cache_file: "./data/user_cache.json"
```
### 3. 启动应用
```bash
python start_web_app.py
```
或者直接运行:
```bash
python web_app.py
```
### 4. 访问界面
打开浏览器,访问:`http://localhost:5000`
## 功能说明
### 1. 手动添加任务
1. 在"添加新任务..."输入框中输入任务描述
2. 选择截止时间(可选)
3. 选择分类(工作/学习/生活/其他)
4. 选择优先级(高/中/低)
5. 点击"添加"按钮
**AI分析过程**
- 系统会调用AI服务分析任务描述
- 自动识别任务发起人和部门
- 匹配飞书用户(如果启用)
- 将任务写入飞书多维表格
### 2. 图片上传分析
1. 点击"上传分析"按钮或拖拽图片到文件选择框
2. 选择截图文件支持JPG、PNG等格式
3. 系统会自动:
- 读取图片到内存
- 调用AI分析图片内容
- 识别任务信息(描述、优先级、发起人等)
- 匹配飞书用户
- 写入飞书表格
- 在界面显示分析结果
**AI分析结果**
- 任务描述
- 优先级(紧急/较紧急/一般/普通)
- 状态(未开始/进行中/已完成)
- 发起人姓名
- 部门信息
- 开始日期
- 截止日期
### 3. 任务管理
- **筛选**:按分类、状态、优先级筛选任务
- **编辑**:点击编辑按钮修改任务
- **删除**:点击删除按钮删除任务
- **完成**:勾选复选框标记任务完成
### 4. 通知功能
- **浏览器通知**:启用后可在任务到期时收到通知
- **桌面通知**显示操作结果和AI分析结果
## API接口
### 1. 上传图片分析
**接口**`POST /api/upload`
**请求**
```json
{
"image": "图片文件"
}
```
**响应**
```json
{
"success": true,
"message": "任务已成功添加到飞书",
"ai_result": {
"task_description": "任务描述",
"priority": "紧急",
"status": "未开始",
"initiator": "张三",
"department": "研发部",
"start_date": "2026-03-03",
"due_date": "2026-03-10"
}
}
```
### 2. 获取用户缓存信息
**接口**`GET /api/user_cache`
**响应**
```json
{
"success": true,
"stats": {
"user_count": 10,
"recent_contact_count": 5,
"cache_age_hours": 0.5,
"is_expired": false
}
}
```
### 3. 获取配置信息
**接口**`GET /api/config`
**响应**
```json
{
"success": true,
"config": {
"memory_processing": false,
"user_matching_enabled": true,
"fallback_to_random": true,
"cache_enabled": true,
"post_process": "keep"
}
}
```
## 配置说明
### 内存处理模式
```yaml
system:
memory_processing: true # 启用内存处理,不保存图片文件
```
### 用户匹配配置
```yaml
system:
user_matching:
enabled: true # 启用用户匹配
fallback_to_random: true # 启用随机匹配回退
cache_enabled: true # 启用缓存
cache_file: "./data/user_cache.json" # 缓存文件路径
```
### 文件处理策略
```yaml
system:
post_process: "keep" # 保留原始文件
# post_process: "delete" # 删除原始文件
# post_process: "move" # 移动到processed_images目录
```
## 使用场景
### 场景1截图转任务
1. 从聊天记录、邮件或文档中截图
2. 上传截图到Web界面
3. AI自动分析截图内容
4. 生成任务并写入飞书表格
### 场景2手动创建任务
1. 在Web界面手动输入任务描述
2. AI分析任务内容提供优化建议
3. 自动匹配任务发起人
4. 写入飞书表格
### 场景3批量处理
1. 准备多个截图文件
2. 逐个上传分析
3. 系统自动处理并写入飞书
## 注意事项
1. **飞书API权限**:确保飞书应用有读取用户列表和写入表格的权限
2. **图片大小**建议图片大小不超过10MB
3. **网络连接**需要稳定的网络连接调用AI和飞书API
4. **浏览器通知**:需要用户授权浏览器通知权限
5. **缓存管理**用户缓存默认24小时过期可手动清理
## 故障排除
### 1. 无法启动应用
- 检查依赖是否安装完整
- 检查配置文件是否正确
- 检查端口5000是否被占用
### 2. AI分析失败
- 检查API密钥是否正确
- 检查网络连接
- 查看日志文件 `app.log`
### 3. 飞书写入失败
- 检查飞书API配置
- 检查表格ID和Base Token
- 确保飞书应用有写入权限
### 4. 用户匹配失败
- 检查飞书API权限用户列表读取
- 检查缓存文件路径
- 查看日志文件了解详细错误
## 技术架构
### 前端
- HTML5 + CSS3 + JavaScript
- 响应式设计,支持移动端
- 实时通知和状态反馈
### 后端
- Flask Web框架
- AI服务集成OpenAI格式API
- 飞书API集成
- 用户缓存管理
### 数据流
1. 用户上传图片/输入任务
2. 前端调用后端API
3. 后端调用AI服务分析
4. 后端匹配飞书用户
5. 后端写入飞书表格
6. 返回结果给前端
7. 前端显示结果和通知
## 扩展功能
### 1. 批量处理
可以扩展支持批量上传和处理多个图片文件
### 2. 实时预览
可以添加图片预览功能,显示上传的截图
### 3. 任务模板
可以添加任务模板功能,快速创建常见任务
### 4. 统计分析
可以添加任务统计和分析功能
### 5. 多语言支持
可以添加多语言界面支持
## 总结
Screen2Feishu Web应用提供了一个完整的解决方案将前端界面与后端功能完美结合。用户可以通过友好的界面轻松创建任务AI自动分析并写入飞书表格大大提高了工作效率。

1028
code (4).html Normal file

File diff suppressed because it is too large Load Diff

45
config.example.yaml Normal file
View File

@@ -0,0 +1,45 @@
# ================= AI 配置 =================
ai:
# 推荐使用以下任一服务:
# 1. Gemini (Google): https://ai.google.dev/
# 2. SiliconFlow (硅基流动): https://cloud.siliconflow.cn/
# 3. DeepSeek: https://platform.deepseek.com/
# 4. Moonshot (月之暗面): https://platform.moonshot.cn/
api_key: "sk-xxxxxxxxxxxxxxxxxxxxxxxx"
base_url: "https://api.siliconflow.cn/v1" # 根据服务商修改
model: "gemini-2.5-pro" # 模型名称,根据服务商调整
# 重试配置(可选)
max_retries: 3 # 最大重试次数
retry_delay: 1.0 # 重试延迟(秒)
# ================= 飞书配置 =================
feishu:
# 飞书开放平台 -> 凭证与基础信息
app_id: "cli_xxxxxxxxxxxx"
app_secret: "xxxxxxxxxxxxxxxxxxxxxxxx"
# 多维表格的 token (浏览器地址栏中 app 开头的那串字符)
app_token: "bascnxxxxxxxxxxxxxxx"
# 数据表的 ID (在多维表格界面 -> 重命名数据表 -> 复制表ID)
table_id: "tblxxxxxxxxxxxx"
# 重试配置(可选)
max_retries: 3 # 最大重试次数
retry_delay: 1.0 # 重试延迟(秒)
# ================= 系统配置 =================
system:
# 监控的文件夹路径 (Windows 路径注意使用双斜杠 \\ 或反斜杠 /)
watch_folder: "./monitor_images"
# 处理完成后的文件处理方式: "delete" (删除) / "move" (移动) / "keep" (保留)
post_process: "move"
# 文件移动的目标文件夹 (当 post_process 为 "move" 时生效)
processed_folder: "./processed_images"
# 日志配置(可选)
log_level: "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
log_file: "app.log" # 日志文件路径,留空则只输出到控制台

View File

@@ -0,0 +1,5 @@
{
"users": [],
"recent_contacts": [],
"last_update_time": 1772430132.497222
}

71
data/user_cache.json Normal file
View File

@@ -0,0 +1,71 @@
{
"users": [
{
"user_id": null,
"name": null,
"email": null,
"mobile": null
},
{
"user_id": null,
"name": null,
"email": null,
"mobile": null
},
{
"user_id": null,
"name": null,
"email": null,
"mobile": null
},
{
"user_id": null,
"name": null,
"email": null,
"mobile": null
},
{
"user_id": null,
"name": null,
"email": null,
"mobile": null
},
{
"user_id": null,
"name": null,
"email": null,
"mobile": null
},
{
"user_id": null,
"name": null,
"email": null,
"mobile": null
},
{
"user_id": null,
"name": null,
"email": null,
"mobile": null
},
{
"user_id": null,
"name": null,
"email": null,
"mobile": null
},
{
"user_id": null,
"name": null,
"email": null,
"mobile": null
}
],
"recent_contacts": [
"马老师",
"周刘路",
"周刘路 (Lucy)",
"张睿思"
],
"last_update_time": 1773119191.9583383
}

317
main.py Normal file
View File

@@ -0,0 +1,317 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Screen2Feishu - AI飞书多维表格自动录入助手
监控文件夹,自动处理图片并写入飞书多维表格
"""
import os
import sys
import time
import argparse
from pathlib import Path
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
# 添加项目根目录到Python路径
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
from services.ai_service import AIService
from services.feishu_service import FeishuService
from utils.logger import setup_logger
from utils.notifier import send_notification
from utils.config_loader import load_config, validate_config
class ImageFileHandler(FileSystemEventHandler):
"""文件系统事件处理器"""
def __init__(self, config, logger):
self.config = config
self.logger = logger
self.ai_service = AIService(config['ai'])
self.feishu_service = FeishuService(config['feishu'])
self.processed_files = set() # 已处理文件记录
self.supported_extensions = {'.png', '.jpg', '.jpeg', '.bmp', '.gif'}
# 创建必要的文件夹
self._create_directories()
def _create_directories(self):
"""创建必要的文件夹"""
watch_folder = Path(self.config['system']['watch_folder'])
watch_folder.mkdir(exist_ok=True)
if self.config['system'].get('post_process') == 'move':
processed_folder = Path(self.config['system']['processed_folder'])
processed_folder.mkdir(exist_ok=True)
def _is_supported_image(self, file_path):
"""检查文件是否为支持的图片格式"""
return Path(file_path).suffix.lower() in self.supported_extensions
def _is_file_ready(self, file_path):
"""检查文件是否已完全写入(通过文件大小稳定判断)"""
try:
size1 = os.path.getsize(file_path)
time.sleep(0.5) # 等待0.5秒
size2 = os.path.getsize(file_path)
return size1 == size2 and size1 > 0
except (OSError, IOError):
return False
def _process_image(self, file_path):
"""处理单个图片文件"""
file_path = Path(file_path)
# 检查是否已处理
if str(file_path) in self.processed_files:
return
self.logger.info(f"开始处理图片: {file_path.name}")
ai_result = None # 初始化变量
try:
# 检查是否启用内存处理
memory_processing = self.config['system'].get('memory_processing', False)
if memory_processing:
# 内存处理模式直接读取图片到内存不保存到processed_images
try:
with open(file_path, 'rb') as f:
image_bytes = f.read()
# 1. AI分析图片内存模式
ai_result = self.ai_service.analyze_image_from_bytes(image_bytes, file_path.name)
if not ai_result:
self.logger.error(f"AI分析失败: {file_path.name}")
return
except Exception as e:
self.logger.error(f"内存处理图片失败: {str(e)}")
# 回退到文件处理模式
memory_processing = False
if not memory_processing:
# 文件处理模式:传统方式
# 1. AI分析图片
ai_result = self.ai_service.analyze_image(str(file_path))
if not ai_result:
self.logger.error(f"AI分析失败: {file_path.name}")
return
# 2. 写入飞书
success = self.feishu_service.add_task(ai_result)
if success:
self.logger.info(f"成功写入飞书: {file_path.name}")
self.processed_files.add(str(file_path))
# 3. 后处理
self._post_process_file(file_path)
# 4. 发送通知
send_notification(
"任务处理成功",
f"已成功处理 {file_path.name} 并写入飞书表格"
)
else:
self.logger.error(f"写入飞书失败: {file_path.name}")
except Exception as e:
self.logger.error(f"处理图片 {file_path.name} 时出错: {str(e)}")
def _post_process_file(self, file_path):
"""文件后处理"""
post_process = self.config['system'].get('post_process', 'keep')
if post_process == 'delete':
try:
file_path.unlink()
self.logger.info(f"已删除文件: {file_path.name}")
except Exception as e:
self.logger.error(f"删除文件失败: {str(e)}")
elif post_process == 'move':
try:
processed_folder = Path(self.config['system']['processed_folder'])
target_path = processed_folder / file_path.name
# 如果目标文件已存在,添加时间戳
if target_path.exists():
timestamp = int(time.time())
target_path = processed_folder / f"{file_path.stem}_{timestamp}{file_path.suffix}"
file_path.rename(target_path)
self.logger.info(f"已移动文件到: {target_path}")
except Exception as e:
self.logger.error(f"移动文件失败: {str(e)}")
def on_created(self, event):
"""文件创建事件处理"""
if event.is_directory:
return
file_path = event.src_path
if not self._is_supported_image(file_path):
return
self.logger.info(f"检测到新文件: {file_path}")
# 等待文件完全写入
if self._is_file_ready(file_path):
self._process_image(file_path)
else:
self.logger.warning(f"文件未完全写入,跳过处理: {file_path}")
def on_modified(self, event):
"""文件修改事件处理"""
if event.is_directory:
return
file_path = event.src_path
if not self._is_supported_image(file_path):
return
# 对于修改事件,也检查文件是否已完全写入
if self._is_file_ready(file_path):
self._process_image(file_path)
def parse_arguments():
"""解析命令行参数"""
parser = argparse.ArgumentParser(
description="AI飞书多维表格自动录入助手",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例用法:
python main.py # 使用默认配置启动
python main.py --config custom.yaml # 使用自定义配置文件
python main.py --test # 测试模式(不监控文件夹)
"""
)
parser.add_argument(
'--config', '-c',
default='config.yaml',
help='配置文件路径 (默认: config.yaml)'
)
parser.add_argument(
'--test', '-t',
action='store_true',
help='测试模式:处理现有文件后退出,不监控文件夹'
)
parser.add_argument(
'--verbose', '-v',
action='store_true',
help='显示详细日志'
)
return parser.parse_args()
def main():
"""主函数"""
args = parse_arguments()
# 加载配置
try:
config = load_config(args.config)
except Exception as e:
print(f" 配置加载失败: {e}")
sys.exit(1)
# 验证配置
try:
validate_config(config)
except Exception as e:
print(f" 配置验证失败: {e}")
sys.exit(1)
# 设置日志
log_level = 'DEBUG' if args.verbose else 'INFO'
logger = setup_logger(
name='screen2feishu',
log_file='app.log',
level=log_level
)
logger.info("=" * 50)
logger.info("Screen2Feishu 启动")
logger.info(f"配置文件: {args.config}")
logger.info(f"监控文件夹: {config['system']['watch_folder']}")
logger.info("=" * 50)
# 创建文件处理器
handler = ImageFileHandler(config, logger)
if args.test:
# 测试模式:处理现有文件
watch_folder = Path(config['system']['watch_folder'])
image_files = [
f for f in watch_folder.iterdir()
if f.is_file() and handler._is_supported_image(f)
]
if not image_files:
logger.info("没有找到待处理的图片文件")
return
logger.info(f"找到 {len(image_files)} 个图片文件,开始处理...")
for image_file in image_files:
handler._process_image(image_file)
logger.info("测试模式处理完成")
return
# 监控模式
observer = Observer()
observer.schedule(
handler,
path=config['system']['watch_folder'],
recursive=False
)
try:
observer.start()
logger.info("文件监控已启动,按 Ctrl+C 停止程序")
# 发送启动通知
send_notification(
"Screen2Feishu 已启动",
f"正在监控文件夹: {config['system']['watch_folder']}"
)
while True:
time.sleep(1)
except KeyboardInterrupt:
logger.info("收到停止信号,正在关闭...")
observer.stop()
# 发送停止通知
send_notification(
"Screen2Feishu 已停止",
"程序已正常关闭"
)
except Exception as e:
logger.error(f"程序运行出错: {str(e)}")
observer.stop()
# 发送错误通知
send_notification(
"Screen2Feishu 错误",
f"程序运行出错: {str(e)}"
)
sys.exit(1)
finally:
observer.join()
logger.info("程序已完全停止")
if __name__ == "__main__":
main()

18
requirements.txt Normal file
View File

@@ -0,0 +1,18 @@
# 核心依赖
openai>=1.0.0
watchdog>=3.0.0
requests>=2.31.0
pyyaml>=6.0.1
# Web应用依赖
flask>=2.3.0
flask-cors>=4.0.0
# 可选依赖
# pillow>=10.0.0 # 如果需要本地图片处理
# torch>=2.0.0 # 如果需要GPU加速
# 开发依赖(可选)
# pytest>=7.0.0 # 用于单元测试
# black>=23.0.0 # 代码格式化
# flake8>=6.0.0 # 代码检查

11
services/__init__.py Normal file
View File

@@ -0,0 +1,11 @@
"""
服务模块
"""
from .ai_service import AIService
from .feishu_service import FeishuService
__all__ = [
'AIService',
'FeishuService'
]

624
services/ai_service.py Normal file
View File

@@ -0,0 +1,624 @@
import base64
import json
import datetime
import time
import re
from typing import Dict, Optional, List
from openai import OpenAI
from openai import APIError, RateLimitError, AuthenticationError
class AIService:
def __init__(self, config):
"""
初始化 AI 服务
:param config: 包含 ai 配置的字典 (来自 config.yaml)
"""
self.api_key = config.get('api_key')
self.base_url = config.get('base_url')
self.model = config.get('model', 'gpt-4o')
self.max_retries = config.get('max_retries', 3)
self.retry_delay = config.get('retry_delay', 1.0)
# 初始化 OpenAI 客户端 (兼容所有支持 OpenAI 格式的 API)
self.client = OpenAI(
api_key=self.api_key,
base_url=self.base_url
)
# 支持的图片格式
self.supported_formats = {'.png', '.jpg', '.jpeg', '.bmp', '.gif'}
# Prompt模板缓存
self.prompt_templates = {}
def _encode_image(self, image_path):
"""将本地图片转换为 Base64 编码"""
try:
with open(image_path, "rb") as image_file:
return base64.b64encode(image_file.read()).decode('utf-8')
except Exception as e:
raise Exception(f"图片编码失败: {str(e)}")
def _encode_image_from_bytes(self, image_bytes: bytes) -> str:
"""将图片字节数据转换为 Base64 编码"""
try:
return base64.b64encode(image_bytes).decode('utf-8')
except Exception as e:
raise Exception(f"图片字节数据编码失败: {str(e)}")
def _validate_image_file(self, image_path):
"""验证图片文件"""
from pathlib import Path
file_path = Path(image_path)
# 检查文件是否存在
if not file_path.exists():
raise FileNotFoundError(f"图片文件不存在: {image_path}")
# 检查文件大小限制为10MB
file_size = file_path.stat().st_size
if file_size > 10 * 1024 * 1024: # 10MB
raise ValueError(f"图片文件过大: {file_size / 1024 / 1024:.2f}MB (最大10MB)")
# 检查文件格式
if file_path.suffix.lower() not in self.supported_formats:
raise ValueError(f"不支持的图片格式: {file_path.suffix}")
return True
def _build_prompt(self, current_date_str: str) -> str:
"""构建AI提示词"""
prompt = f"""
你是一个专业的项目管理助手。当前系统日期是:{current_date_str}
你的任务是从用户上传的图片(可能是聊天记录、邮件、文档截图)中提取任务信息。
请严格按照以下 JSON 格式返回结果:
{{
"task_description": "任务的具体描述,简练概括",
"priority": "必须从以下选项中选择一个: ['紧急', '较紧急', '一般', '普通']",
"status": "必须从以下选项中选择一个: ['已停滞','待开始', '进行中', '已完成']",
"latest_progress": "图片中提到的最新进展,如果没有则留空字符串",
"initiator": "任务发起人姓名。请仔细识别图片中的发件人/发送者名字。如果是邮件截图,请识别发件人;如果是聊天记录,请识别发送者。如果没有明确的发起人,则留空字符串。",
"department": "发起人部门,如果没有则留空字符串",
"start_date": "YYYY-MM-DD 格式的日期字符串。如果提到'今天'就是当前日期,'下周一'请根据当前日期计算。如果没提到则返回 null",
"due_date": "YYYY-MM-DD 格式的截止日期字符串。逻辑同上,如果没提到则返回 null"
}}
注意:
1. 如果图片中包含多个任务,请只提取最核心的一个。
2. 请特别关注图片中的发件人/发送者信息,准确提取姓名。
3. 如果识别到的名字可能存在重名,请在任务描述中添加提示信息。
4. 不要返回 Markdown 代码块标记(如 ```json直接返回纯 JSON 字符串。
5. 确保返回的 JSON 格式正确,可以被 Python 的 json.loads() 解析。
"""
return prompt
def _parse_ai_response(self, content: Optional[str]) -> Optional[Dict]:
"""解析AI响应"""
if not content:
raise ValueError("AI响应内容为空")
try:
# 清理可能的 markdown 标记
content = content.replace("```json", "").replace("```", "").strip()
# 尝试修复常见的JSON格式问题
# 1. 处理未闭合的引号
content = self._fix_json_string(content)
# 2. 处理转义字符问题
content = self._fix_json_escapes(content)
# 解析JSON
result_dict = json.loads(content)
# 验证必需的字段
required_fields = ['task_description', 'priority', 'status']
for field in required_fields:
if field not in result_dict:
raise ValueError(f"AI响应缺少必需字段: {field}")
# 验证选项值
valid_priorities = ['紧急', '较紧急', '一般', '普通']
valid_statuses = ['已停滞','待开始', '进行中', '已完成']
if result_dict.get('priority') not in valid_priorities:
raise ValueError(f"无效的优先级: {result_dict.get('priority')}")
if result_dict.get('status') not in valid_statuses:
raise ValueError(f"无效的状态: {result_dict.get('status')}")
return result_dict
except json.JSONDecodeError as e:
# 尝试更详细的错误修复
try:
# 如果标准解析失败,尝试使用更宽松的解析
content = self._aggressive_json_fix(content)
result_dict = json.loads(content)
# 重新验证字段
required_fields = ['task_description', 'priority', 'status']
for field in required_fields:
if field not in result_dict:
raise ValueError(f"AI响应缺少必需字段: {field}")
# 重新验证选项值
valid_priorities = ['紧急', '较紧急', '一般', '普通']
valid_statuses = ['已停滞','待开始', '进行中', '已完成']
if result_dict.get('priority') not in valid_priorities:
raise ValueError(f"无效的优先级: {result_dict.get('priority')}")
if result_dict.get('status') not in valid_statuses:
raise ValueError(f"无效的状态: {result_dict.get('status')}")
return result_dict
except Exception as retry_error:
raise ValueError(f"AI响应不是有效的JSON: {str(e)} (修复后错误: {str(retry_error)})")
except Exception as e:
raise ValueError(f"解析AI响应失败: {str(e)}")
def _fix_json_string(self, content: str) -> str:
"""修复JSON字符串中的未闭合引号问题"""
import re
# 查找可能未闭合的字符串
# 匹配模式:从引号开始,但没有对应的闭合引号
lines = content.split('\n')
fixed_lines = []
for line in lines:
# 检查行中是否有未闭合的引号
in_string = False
escaped = False
fixed_line = []
for char in line:
if escaped:
fixed_line.append(char)
escaped = False
continue
if char == '\\':
escaped = True
fixed_line.append(char)
continue
if char == '"':
if in_string:
in_string = False
else:
in_string = True
fixed_line.append(char)
else:
fixed_line.append(char)
# 如果行结束时仍在字符串中,添加闭合引号
if in_string:
fixed_line.append('"')
fixed_lines.append(''.join(fixed_line))
return '\n'.join(fixed_lines)
def _fix_json_escapes(self, content: str) -> str:
"""修复JSON转义字符问题"""
# 注意我们不应该转义JSON结构中的引号只转义字符串内容中的引号
# 这个函数暂时不处理换行符因为JSON中的换行符是有效的
# 更复杂的转义修复应该在JSON解析后进行
return content
def _aggressive_json_fix(self, content: str) -> str:
"""更激进的JSON修复策略"""
# 1. 移除可能的非JSON内容
content = re.sub(r'^[^{]*', '', content) # 移除JSON前的非JSON内容
content = re.sub(r'[^}]*$', '', content) # 移除JSON后的非JSON内容
# 2. 确保JSON对象闭合
if not content.strip().endswith('}'):
content = content.strip() + '}'
# 3. 确保JSON对象开始
if not content.strip().startswith('{'):
content = '{' + content.strip()
# 4. 处理常见的AI响应格式问题
# 移除可能的Markdown代码块标记
content = content.replace('```json', '').replace('```', '')
# 5. 处理可能的多余空格和换行
content = ' '.join(content.split())
return content
def _call_ai_with_retry(self, image_path: str, prompt: str) -> Optional[str]:
"""调用AI API带重试机制"""
base64_image = self._encode_image(image_path)
for attempt in range(self.max_retries):
try:
# 尝试使用response_format参数适用于OpenAI格式的API
try:
response = self.client.chat.completions.create(
model=self.model,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{base64_image}"
}
}
]
}
],
response_format={"type": "json_object"},
max_tokens=2000
)
except Exception as e:
# 如果response_format参数不支持尝试不使用该参数
print(f"⚠️ response_format参数不支持尝试不使用该参数: {str(e)}")
response = self.client.chat.completions.create(
model=self.model,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{base64_image}"
}
}
]
}
],
max_tokens=2000
)
if not response.choices:
return None
content = response.choices[0].message.content
return content if content else None
except RateLimitError as e:
if attempt < self.max_retries - 1:
wait_time = self.retry_delay * (2 ** attempt) # 指数退避
time.sleep(wait_time)
continue
raise Exception(f"API速率限制: {str(e)}")
except AuthenticationError as e:
raise Exception(f"API认证失败: {str(e)}")
except APIError as e:
if attempt < self.max_retries - 1:
wait_time = self.retry_delay * (2 ** attempt)
time.sleep(wait_time)
continue
raise Exception(f"API调用失败: {str(e)}")
except Exception as e:
if attempt < self.max_retries - 1:
wait_time = self.retry_delay * (2 ** attempt)
time.sleep(wait_time)
continue
raise Exception(f"未知错误: {str(e)}")
raise Exception(f"AI调用失败已重试 {self.max_retries}")
def _call_ai_with_retry_from_bytes(self, image_bytes: bytes, prompt: str, image_name: str = "memory_image") -> Optional[str]:
"""调用AI API带重试机制从内存字节数据"""
base64_image = self._encode_image_from_bytes(image_bytes)
for attempt in range(self.max_retries):
try:
# 尝试使用response_format参数适用于OpenAI格式的API
try:
response = self.client.chat.completions.create(
model=self.model,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{base64_image}"
}
}
]
}
],
response_format={"type": "json_object"},
max_tokens=2000
)
except Exception as e:
# 如果response_format参数不支持尝试不使用该参数
print(f"⚠️ response_format参数不支持尝试不使用该参数: {str(e)}")
response = self.client.chat.completions.create(
model=self.model,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{base64_image}"
}
}
]
}
],
max_tokens=2000
)
if not response.choices:
return None
content = response.choices[0].message.content
return content if content else None
except RateLimitError as e:
if attempt < self.max_retries - 1:
wait_time = self.retry_delay * (2 ** attempt) # 指数退避
time.sleep(wait_time)
continue
raise Exception(f"API速率限制: {str(e)}")
except AuthenticationError as e:
raise Exception(f"API认证失败: {str(e)}")
except APIError as e:
if attempt < self.max_retries - 1:
wait_time = self.retry_delay * (2 ** attempt)
time.sleep(wait_time)
continue
raise Exception(f"API调用失败: {str(e)}")
except Exception as e:
if attempt < self.max_retries - 1:
wait_time = self.retry_delay * (2 ** attempt)
time.sleep(wait_time)
continue
raise Exception(f"未知错误: {str(e)}")
raise Exception(f"AI调用失败已重试 {self.max_retries}")
for attempt in range(self.max_retries):
try:
# 尝试使用response_format参数适用于OpenAI格式的API
try:
response = self.client.chat.completions.create(
model=self.model,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{base64_image}"
}
}
]
}
],
response_format={"type": "json_object"},
max_tokens=2000
)
except Exception as e:
# 如果response_format参数不支持尝试不使用该参数
print(f"⚠️ response_format参数不支持尝试不使用该参数: {str(e)}")
response = self.client.chat.completions.create(
model=self.model,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{base64_image}"
}
}
]
}
],
max_tokens=2000
)
if not response.choices:
return None
content = response.choices[0].message.content
return content if content else None
except RateLimitError as e:
if attempt < self.max_retries - 1:
wait_time = self.retry_delay * (2 ** attempt) # 指数退避
time.sleep(wait_time)
continue
raise Exception(f"API速率限制: {str(e)}")
except AuthenticationError as e:
raise Exception(f"API认证失败: {str(e)}")
except APIError as e:
if attempt < self.max_retries - 1:
wait_time = self.retry_delay * (2 ** attempt)
time.sleep(wait_time)
continue
raise Exception(f"API调用失败: {str(e)}")
except Exception as e:
if attempt < self.max_retries - 1:
wait_time = self.retry_delay * (2 ** attempt)
time.sleep(wait_time)
continue
raise Exception(f"未知错误: {str(e)}")
raise Exception(f"AI调用失败已重试 {self.max_retries}")
def analyze_image(self, image_path):
"""
核心方法:发送图片到 AI 并获取结构化数据
:param image_path: 图片文件的路径
:return: 解析后的字典 (Dict)
"""
from pathlib import Path
file_path = Path(image_path)
# 使用sys.stdout.write替代print避免编码问题
import sys
sys.stdout.write(f" [AI] 正在分析图片: {file_path.name} ...\n")
sys.stdout.flush()
try:
# 1. 验证图片文件
self._validate_image_file(image_path)
# 2. 获取当前日期 (用于辅助 AI 推断相对时间)
now = datetime.datetime.now()
current_date_str = now.strftime("%Y-%m-%d %A") # 例如: 2023-10-27 Sunday
# 3. 构建 Prompt
prompt = self._build_prompt(current_date_str)
# 4. 调用AI API带重试机制
content = self._call_ai_with_retry(image_path, prompt)
# 5. 解析结果
if not content:
import sys
sys.stdout.write(f" [AI] AI返回空内容\n")
sys.stdout.flush()
return None
result_dict = self._parse_ai_response(content)
# 记录成功日志
if result_dict:
task_desc = result_dict.get('task_description', '')
if task_desc and len(task_desc) > 30:
task_desc = task_desc[:30] + "..."
import sys
sys.stdout.write(f" [AI] 识别成功: {task_desc}\n")
sys.stdout.flush()
return result_dict
except Exception as e:
import sys
sys.stdout.write(f" [AI] 分析失败: {str(e)}\n")
sys.stdout.flush()
return None
def analyze_image_from_bytes(self, image_bytes: bytes, image_name: str = "memory_image"):
"""
核心方法:从内存中的图片字节数据发送到 AI 并获取结构化数据
:param image_bytes: 图片的字节数据
:param image_name: 图片名称(用于日志)
:return: 解析后的字典 (Dict)
"""
# 使用sys.stdout.write替代print避免编码问题
import sys
sys.stdout.write(f" [AI] 正在分析内存图片: {image_name} ...\n")
sys.stdout.flush()
try:
# 1. 验证图片数据大小
if len(image_bytes) > 10 * 1024 * 1024: # 10MB
raise ValueError(f"图片数据过大: {len(image_bytes) / 1024 / 1024:.2f}MB (最大10MB)")
# 2. 获取当前日期 (用于辅助 AI 推断相对时间)
now = datetime.datetime.now()
current_date_str = now.strftime("%Y-%m-%d %A") # 例如: 2023-10-27 Sunday
# 3. 构建 Prompt
prompt = self._build_prompt(current_date_str)
# 4. 调用AI API带重试机制
content = self._call_ai_with_retry_from_bytes(image_bytes, prompt, image_name)
# 5. 解析结果
if not content:
import sys
sys.stdout.write(f" [AI] AI返回空内容\n")
sys.stdout.flush()
return None
result_dict = self._parse_ai_response(content)
# 记录成功日志
if result_dict:
task_desc = result_dict.get('task_description', '')
if task_desc and len(task_desc) > 30:
task_desc = task_desc[:30] + "..."
import sys
sys.stdout.write(f" [AI] 识别成功: {task_desc}\n")
sys.stdout.flush()
return result_dict
except Exception as e:
import sys
sys.stdout.write(f" [AI] 分析失败: {str(e)}\n")
sys.stdout.flush()
return None
def analyze_image_batch(self, image_paths: List[str]) -> Dict[str, Optional[Dict]]:
"""
批量分析图片
:param image_paths: 图片文件路径列表
:return: 字典,键为图片路径,值为分析结果
"""
results = {}
for image_path in image_paths:
try:
result = self.analyze_image(image_path)
results[image_path] = result
except Exception as e:
import sys
sys.stdout.write(f" [AI] 批量处理失败 {image_path}: {str(e)}\n")
sys.stdout.flush()
results[image_path] = None
return results
# ================= 单元测试 =================
if __name__ == "__main__":
# 在这里填入你的配置进行测试
test_config = {
"api_key": "sk-xxxxxxxxxxxxxxxxxxxxxxxx",
"base_url": "https://api.openai.com/v1",
"model": "gpt-4o"
}
# 确保目录下有一张名为 test_img.jpg 的图片
import os
if os.path.exists("test_img.jpg"):
ai = AIService(test_config)
res = ai.analyze_image("test_img.jpg")
import sys
sys.stdout.write(json.dumps(res, indent=2, ensure_ascii=False) + "\n")
sys.stdout.flush()
else:
import sys
sys.stdout.write("请在同级目录下放一张 test_img.jpg 用于测试\n")
sys.stdout.flush()

489
services/feishu_service.py Normal file
View File

@@ -0,0 +1,489 @@
import requests
import json
import time
import logging
from datetime import datetime
from typing import Dict, Optional, List
from pathlib import Path
from utils.user_cache import UserCacheManager
class FeishuService:
def __init__(self, config):
"""
初始化飞书服务
:param config: 包含飞书配置的字典 (来自 config.yaml)
"""
self.app_id = config.get('app_id')
self.app_secret = config.get('app_secret')
self.app_token = config.get('app_token') # 多维表格的 Base Token
self.table_id = config.get('table_id') # 数据表 ID
# 内部变量,用于缓存 Token
self._tenant_access_token = None
self._token_expire_time = 0
# 重试配置
self.max_retries = config.get('max_retries', 3)
self.retry_delay = config.get('retry_delay', 1.0)
# 用户匹配配置
user_matching_config = config.get('user_matching', {})
self.user_matching_enabled = user_matching_config.get('enabled', True)
self.fallback_to_random = user_matching_config.get('fallback_to_random', True)
self.cache_enabled = user_matching_config.get('cache_enabled', True)
self.cache_file = user_matching_config.get('cache_file', './data/user_cache.json')
# 用户缓存管理器
self.user_cache = UserCacheManager(self.cache_file) if self.cache_enabled else None
# 支持的字段类型映射
self.field_type_map = {
'text': 'text',
'number': 'number',
'date': 'date',
'select': 'single_select',
'multi_select': 'multiple_select',
'checkbox': 'checkbox',
'url': 'url',
'email': 'email',
'phone': 'phone',
'user': 'user' # 用户字段类型
}
# 验证必需的配置
self._validate_config()
def _validate_config(self):
"""验证飞书配置"""
if not self.app_id:
raise ValueError("飞书配置缺少 app_id")
if not self.app_secret:
raise ValueError("飞书配置缺少 app_secret")
if not self.app_token:
raise ValueError("飞书配置缺少 app_token")
if not self.table_id:
raise ValueError("飞书配置缺少 table_id")
# 验证用户匹配配置
if self.user_matching_enabled and not self.cache_enabled:
logging.warning("用户匹配已启用但缓存未启用,可能影响性能")
def _get_token(self, retry_count: int = 0) -> Optional[str]:
"""
获取 tenant_access_token (带缓存机制和重试)
如果 Token 还有效,直接返回;否则重新请求。
"""
now = time.time()
# 如果 token 存在且离过期还有60秒以上直接复用
if self._tenant_access_token and now < self._token_expire_time - 60:
return self._tenant_access_token
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
headers = {"Content-Type": "application/json; charset=utf-8"}
payload = {
"app_id": self.app_id,
"app_secret": self.app_secret
}
try:
resp = requests.post(url, headers=headers, json=payload, timeout=10)
resp_data = resp.json()
if resp_data.get("code") == 0:
self._tenant_access_token = resp_data.get("tenant_access_token")
# expire_in 通常是 7200 秒,我们记录下过期的绝对时间戳
self._token_expire_time = now + resp_data.get("expire", 7200)
logging.info("🔄 [Feishu] Token 已刷新")
return self._tenant_access_token
else:
error_msg = resp_data.get("msg", "未知错误")
raise Exception(f"获取飞书 Token 失败: {error_msg}")
except requests.exceptions.RequestException as e:
if retry_count < self.max_retries:
wait_time = self.retry_delay * (2 ** retry_count)
logging.warning(f"网络错误,{wait_time}秒后重试: {str(e)}")
time.sleep(wait_time)
return self._get_token(retry_count + 1)
logging.error(f" [Feishu] 网络错误,已重试 {self.max_retries} 次: {str(e)}")
return None
except Exception as e:
logging.error(f" [Feishu] 认证错误: {str(e)}")
return None
def _date_to_timestamp(self, date_str: str) -> Optional[int]:
"""
将 YYYY-MM-DD 字符串转换为飞书需要的毫秒级时间戳
"""
if not date_str or date_str == "null":
return None
try:
# 假设 AI 返回的是 YYYY-MM-DD
dt = datetime.strptime(date_str, "%Y-%m-%d")
# 转换为毫秒时间戳
return int(dt.timestamp() * 1000)
except ValueError:
logging.warning(f"⚠️ [Feishu] 日期格式无法解析: {date_str},将留空。")
return None
def _validate_ai_data(self, ai_data: Dict) -> bool:
"""验证AI返回的数据"""
required_fields = ['task_description', 'priority', 'status']
for field in required_fields:
if field not in ai_data:
logging.error(f" [Feishu] AI数据缺少必需字段: {field}")
return False
# 验证选项值
valid_priorities = ['紧急', '较紧急', '一般', '普通']
valid_statuses = [' 已停滞', '待开始', '进行中', ',已完成']
if ai_data.get('priority') not in valid_priorities:
logging.error(f" [Feishu] 无效的优先级: {ai_data.get('priority')}")
return False
if ai_data.get('status') not in valid_statuses:
logging.error(f" [Feishu] 无效的状态: {ai_data.get('status')}")
return False
return True
def _build_fields(self, ai_data: Dict) -> Dict:
"""构建飞书字段数据"""
# --- 字段映射区域 (Key 必须与飞书表格列名完全一致) ---
fields = {
"任务描述": ai_data.get("task_description", ""),
"重要紧急程度": ai_data.get("priority", "普通"), # 默认值防止报错
"进展": ai_data.get("status", "待开始"),
"最新进展记录": ai_data.get("latest_progress", "进行中,请及时更新"),
# 任务发起方.部门是多选字段,需要列表格式
"任务发起方.部门": self._build_multi_select_field(ai_data.get("department", "")) # 修复字段名:添加点号
}
# 处理任务发起方
initiator_name = ai_data.get("initiator", "")
if initiator_name and self.user_matching_enabled:
# 尝试匹配用户
user_match = self._match_initiator(initiator_name)
if user_match:
if user_match.get('matched'):
# 成功匹配到用户ID
fields["任务发起方"] = self._build_user_field(user_match['user_id'])
logging.info(f"成功匹配任务发起方: {initiator_name} -> {user_match['user_name']}")
else:
# 未匹配到用户,添加待确认标记到任务描述
fields["任务描述"] = f"[待确认发起人] {fields['任务描述']}"
# 在任务发起方.部门字段中添加待确认信息(作为提示)
existing_dept = ai_data.get("department", "")
if existing_dept:
fields["任务发起方.部门"] = self._build_multi_select_field(f"{existing_dept} (待确认: {initiator_name})")
else:
fields["任务发起方.部门"] = self._build_multi_select_field(f"待确认: {initiator_name}")
logging.warning(f"未匹配到用户: {initiator_name},标记为待确认")
# 添加到最近联系人
if self.user_cache:
self.user_cache.add_recent_contact(initiator_name)
else:
# 匹配失败,使用随机选择或留空
if self.fallback_to_random and self.user_cache:
random_contact = self.user_cache.get_random_recent_contact()
if random_contact:
fields["任务描述"] = f"[随机匹配] {fields['任务描述']}"
# 在任务发起方.部门字段中添加随机匹配信息
existing_dept = ai_data.get("department", "")
if existing_dept:
fields["任务发起方.部门"] = self._build_multi_select_field(f"{existing_dept} (随机: {random_contact})")
else:
fields["任务发起方.部门"] = self._build_multi_select_field(f"随机: {random_contact}")
logging.info(f"随机匹配任务发起方: {random_contact}")
else:
fields["任务描述"] = f"[待确认发起人] {fields['任务描述']}"
# 在任务发起方.部门字段中添加待确认信息
existing_dept = ai_data.get("department", "")
if existing_dept:
fields["任务发起方.部门"] = self._build_multi_select_field(f"{existing_dept} (待确认: {initiator_name})")
else:
fields["任务发起方.部门"] = self._build_multi_select_field(f"待确认: {initiator_name}")
logging.warning(f"无最近联系人可用,标记为待确认: {initiator_name}")
else:
fields["任务描述"] = f"[待确认发起人] {fields['任务描述']}"
# 在任务发起方.部门字段中添加待确认信息
existing_dept = ai_data.get("department", "")
if existing_dept:
fields["任务发起方.部门"] = self._build_multi_select_field(f"{existing_dept} (待确认: {initiator_name})")
else:
fields["任务发起方.部门"] = self._build_multi_select_field(f"待确认: {initiator_name}")
logging.warning(f"未匹配到用户且未启用随机匹配: {initiator_name}")
elif initiator_name:
# 用户匹配未启用,直接使用名字
# 在任务发起方.部门字段中添加发起人信息
existing_dept = ai_data.get("department", "")
if existing_dept:
fields["任务发起方.部门"] = self._build_multi_select_field(f"{existing_dept} (发起人: {initiator_name})")
else:
fields["任务发起方.部门"] = self._build_multi_select_field(f"发起人: {initiator_name}")
# 移除用户字段避免UserFieldConvFail错误如果匹配失败
if "任务发起方" not in fields:
fields.pop("任务发起方", None)
# 处理日期字段 (AI 返回的是字符串,飞书写入最好用时间戳)
start_date = ai_data.get("start_date")
if start_date and isinstance(start_date, str):
start_ts = self._date_to_timestamp(start_date)
if start_ts:
fields["开始日期"] = start_ts
due_date = ai_data.get("due_date")
if due_date and isinstance(due_date, str):
due_ts = self._date_to_timestamp(due_date)
if due_ts:
fields["预计完成日期"] = due_ts
return fields
def _build_multi_select_field(self, value: str) -> Optional[List]:
"""构建飞书多选字段"""
if not value:
return None
# 多选字段需要以列表格式提供
# 格式:["选项1", "选项2"]
return [value]
def _call_feishu_api(self, url: str, headers: Dict, payload: Dict, retry_count: int = 0) -> Optional[Dict]:
"""调用飞书API带重试机制"""
try:
response = requests.post(url, headers=headers, json=payload, timeout=30)
res_json = response.json()
if res_json.get("code") == 0:
return res_json
else:
error_msg = res_json.get("msg", "未知错误")
error_code = res_json.get("code", "未知错误码")
raise Exception(f"飞书API错误 {error_code}: {error_msg}")
except requests.exceptions.RequestException as e:
if retry_count < self.max_retries:
wait_time = self.retry_delay * (2 ** retry_count)
logging.warning(f"网络错误,{wait_time}秒后重试: {str(e)}")
time.sleep(wait_time)
return self._call_feishu_api(url, headers, payload, retry_count + 1)
logging.error(f" [Feishu] 网络错误,已重试 {self.max_retries} 次: {str(e)}")
return None
except Exception as e:
logging.error(f" [Feishu] API调用失败: {str(e)}")
return None
def add_task(self, ai_data: Optional[Dict]) -> bool:
"""
将 AI 识别的数据写入多维表格
:param ai_data: AI 返回的 JSON 字典
:return: 是否成功
"""
# 验证AI数据
if not ai_data or not self._validate_ai_data(ai_data):
return False
token = self._get_token()
if not token:
logging.error(" [Feishu] 无法获取 Token跳过写入。")
return False
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{self.app_token}/tables/{self.table_id}/records"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json; charset=utf-8"
}
# 构建字段数据
fields = self._build_fields(ai_data)
payload = {"fields": fields}
# 调用API
res_json = self._call_feishu_api(url, headers, payload)
if res_json and res_json.get("code") == 0:
record_id = res_json['data']['record']['record_id']
logging.info(f" [Feishu] 成功写入一条记录!(Record ID: {record_id})")
return True
else:
logging.error(f" [Feishu] 写入失败: {json.dumps(res_json, ensure_ascii=False) if res_json else '无响应'}")
return False
def add_task_batch(self, ai_data_list: List[Dict]) -> Dict[str, bool]:
"""
批量写入任务到飞书
:param ai_data_list: AI数据列表
:return: 字典,键为数据索引,值为是否成功
"""
results = {}
for index, ai_data in enumerate(ai_data_list):
try:
success = self.add_task(ai_data)
results[str(index)] = success
except Exception as e:
logging.error(f" [Feishu] 批量写入失败 (索引 {index}): {str(e)}")
results[str(index)] = False
return results
def _match_initiator(self, initiator_name: str) -> Optional[Dict]:
"""
匹配任务发起人
Args:
initiator_name: AI识别的发起人名字
Returns:
匹配结果字典包含matched, user_id, user_name等字段
"""
if not initiator_name or not self.user_matching_enabled:
return None
# 确保用户缓存已初始化
if not self.user_cache:
return None
# 检查缓存是否过期,如果过期则重新获取用户列表
if self.user_cache.is_cache_expired(max_age_hours=24):
logging.info("用户缓存已过期,重新获取用户列表")
users = self._get_user_list()
if users:
self.user_cache.update_users(users)
# 尝试匹配用户
matched_user = self.user_cache.match_user_by_name(initiator_name)
if matched_user:
return {
'matched': True,
'user_id': matched_user.get('user_id'),
'user_name': matched_user.get('name')
}
else:
return {
'matched': False,
'user_name': initiator_name
}
def _get_user_list(self) -> Optional[List[Dict]]:
"""
从飞书API获取用户列表
Returns:
用户列表,每个用户包含 name, user_id 等字段
"""
token = self._get_token()
if not token:
logging.error(" [Feishu] 无法获取 Token跳过用户列表获取。")
return None
url = "https://open.feishu.cn/open-apis/contact/v3/users"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json; charset=utf-8"
}
try:
# 分页获取用户列表
all_users = []
page_token = None
while True:
params = {"page_size": 100}
if page_token:
params["page_token"] = page_token
response = requests.get(url, headers=headers, params=params, timeout=30)
res_json = response.json()
if res_json.get("code") == 0:
users = res_json.get("data", {}).get("items", [])
for user in users:
# 提取用户信息
user_info = {
'user_id': user.get('user_id'),
'name': user.get('name'),
'email': user.get('email'),
'mobile': user.get('mobile')
}
all_users.append(user_info)
# 检查是否有更多页面
page_token = res_json.get("data", {}).get("page_token")
if not page_token:
break
else:
error_msg = res_json.get("msg", "未知错误")
logging.error(f"获取用户列表失败: {error_msg}")
break
logging.info(f"成功获取 {len(all_users)} 个用户")
return all_users
except Exception as e:
logging.error(f"获取用户列表时出错: {str(e)}")
return None
def _build_user_field(self, user_id: str) -> Optional[Dict]:
"""
构建飞书用户字段
Args:
user_id: 用户ID
Returns:
用户字段字典
"""
if not user_id:
return None
return {
"id": user_id
}
# ================= 单元测试代码 =================
if __name__ == "__main__":
# 设置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# 如果你直接运行这个文件,会执行下面的测试代码
# 这里填入真实的配置进行测试
test_config = {
"app_id": "cli_xxxxxxxxxxxx", # 换成你的 App ID
"app_secret": "xxxxxxxxxxxxxx", # 换成你的 App Secret
"app_token": "basxxxxxxxxxxxxx", # 换成你的 Base Token
"table_id": "tblxxxxxxxxxxxxx" # 换成你的 Table ID
}
# 模拟 AI 返回的数据
mock_ai_data = {
"task_description": "测试任务:编写 Feishu Service 模块",
"priority": "普通", # 确保飞书表格里有这个选项
"status": "进行中", # 确保飞书表格里有这个选项
"latest_progress": "代码框架已完成,正在调试 API",
"initiator": "Python脚本",
"department": "研发部",
"start_date": "2023-10-27",
"due_date": "2023-10-30"
}
try:
fs = FeishuService(test_config)
success = fs.add_task(mock_ai_data)
import sys
if success:
sys.stdout.write("测试成功!\n")
else:
sys.stdout.write("测试失败!\n")
sys.stdout.flush()
except Exception as e:
import sys
sys.stdout.write(f"测试出错: {str(e)}\n")
sys.stdout.flush()

193
setup.py Normal file
View File

@@ -0,0 +1,193 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Screen2Feishu 安装和设置脚本
"""
import os
import sys
import subprocess
import shutil
from pathlib import Path
def print_header(text):
"""打印标题"""
print("\n" + "=" * 60)
print(f" {text}")
print("=" * 60)
def print_step(text):
"""打印步骤"""
print(f"\n{text}")
def check_python_version():
"""检查Python版本"""
print_step("检查Python版本...")
version = sys.version_info
if version.major < 3 or (version.major == 3 and version.minor < 8):
print(f" Python版本过低: {sys.version}")
print("请使用 Python 3.8 或更高版本")
return False
print(f" Python版本: {sys.version}")
return True
def install_dependencies():
"""安装依赖"""
print_step("安装依赖...")
try:
# 检查是否已安装pip
subprocess.run([sys.executable, "-m", "pip", "--version"],
check=True, capture_output=True)
# 安装依赖
print("正在安装核心依赖...")
subprocess.run([
sys.executable, "-m", "pip", "install",
"-r", "requirements.txt"
], check=True)
print(" 依赖安装完成")
return True
except subprocess.CalledProcessError as e:
print(f" 依赖安装失败: {e}")
return False
except Exception as e:
print(f" 发生错误: {e}")
return False
def create_directories():
"""创建必要的目录"""
print_step("创建目录...")
directories = [
"monitor_images",
"processed_images",
"logs"
]
for directory in directories:
path = Path(directory)
if not path.exists():
path.mkdir(exist_ok=True)
print(f" 创建目录: {directory}")
else:
print(f" 目录已存在: {directory}")
return True
def create_config():
"""创建配置文件"""
print_step("创建配置文件...")
config_path = Path("config.yaml")
example_path = Path("config.example.yaml")
if config_path.exists():
print(" 配置文件已存在,跳过创建")
return True
if not example_path.exists():
print(" 示例配置文件不存在")
return False
# 复制示例配置文件
shutil.copy(example_path, config_path)
print(" 已创建 config.yaml请根据需要编辑配置")
# 显示配置说明
print("\n📝 配置说明:")
print("1. 编辑 config.yaml 文件")
print("2. 填入你的 AI API Key")
print("3. 填入你的飞书应用信息:")
print(" - app_id")
print(" - app_secret")
print(" - app_token")
print(" - table_id")
print("4. 根据需要调整其他配置")
return True
def show_usage():
"""显示使用说明"""
print_step("使用说明:")
print("""
1. 配置文件:
- 编辑 config.yaml填入你的 API Key 和飞书信息
2. 启动程序:
python main.py
3. 测试模式:
python main.py --test
4. 查看帮助:
python main.py --help
5. 运行测试:
python -m unittest discover tests
6. 查看文档:
- README.md - 项目说明
- docs/OPTIMIZED_R&D.md - 开发文档
- docs/PROJECT_IMPROVEMENT_PLAN.md - 改进计划
""")
def main():
"""主函数"""
print_header("Screen2Feishu 安装向导")
print("欢迎使用 Screen2Feishu 安装向导!")
print("本脚本将帮助你完成以下步骤:")
print("1. 检查Python版本")
print("2. 安装依赖")
print("3. 创建必要的目录")
print("4. 创建配置文件")
# 询问用户是否继续
response = input("\n是否继续? (y/n): ").strip().lower()
if response not in ['y', 'yes']:
print("安装已取消")
return
# 执行安装步骤
steps = [
("检查Python版本", check_python_version),
("安装依赖", install_dependencies),
("创建目录", create_directories),
("创建配置文件", create_config),
]
all_success = True
for step_name, step_func in steps:
print_header(step_name)
if not step_func():
print(f" {step_name} 失败")
all_success = False
break
if all_success:
print_header("安装完成!")
print(" 所有步骤完成!")
show_usage()
print("\n🎉 Screen2Feishu 安装成功!")
print("请编辑 config.yaml 文件,然后运行: python main.py")
else:
print_header("安装失败")
print(" 安装过程中出现问题,请检查错误信息并重试")
if __name__ == "__main__":
main()

71
start_web_app.py Normal file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
启动Screen2Feishu Web应用
"""
import os
import sys
import subprocess
import webbrowser
from pathlib import Path
def main():
"""启动Web应用"""
print("=" * 60)
print("Screen2Feishu Web应用启动器")
print("=" * 60)
# 检查依赖
print("检查依赖...")
try:
import flask
import flask_cors
print("✓ Flask 已安装")
print("✓ Flask-CORS 已安装")
except ImportError as e:
print(f"✗ 缺少依赖: {str(e)}")
print("请运行: pip install flask flask-cors")
return 1
# 检查配置文件
config_path = Path("config.yaml")
if not config_path.exists():
print("✗ 配置文件不存在: config.yaml")
print("请复制 config.example.yaml 为 config.yaml 并配置")
return 1
# 检查必要的目录
directories = ["monitor_images", "processed_images", "data", "templates"]
for directory in directories:
Path(directory).mkdir(exist_ok=True)
# 检查模板文件
template_path = Path("templates/index.html")
if not template_path.exists():
print("✗ 模板文件不存在: templates/index.html")
print("请确保模板文件已创建")
return 1
print("✓ 所有检查通过")
print()
# 启动Web服务器
print("启动Web服务器...")
print("服务器地址: http://localhost:5000")
print("按 Ctrl+C 停止服务器")
print()
try:
# 启动Flask应用
subprocess.run([sys.executable, "web_app.py"])
except KeyboardInterrupt:
print("\n服务器已停止")
except Exception as e:
print(f"启动失败: {str(e)}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())

829
templates/index.html Normal file
View File

@@ -0,0 +1,829 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Screen2Feishu - AI任务管理</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
body {
background: #f5f7fa;
color: #333;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
background: #2c3e50;
color: white;
padding: 20px 0;
margin-bottom: 30px;
}
header h1 {
font-size: 24px;
font-weight: 600;
text-align: center;
}
.main-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 768px) {
.main-content {
grid-template-columns: 1fr;
}
}
.card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.card h2 {
font-size: 18px;
margin-bottom: 15px;
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 8px;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #555;
}
input[type="text"],
input[type="datetime-local"],
select,
input[type="file"] {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
input[type="text"]:focus,
input[type="datetime-local"]:focus,
select:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
}
.radio-group {
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.radio-item {
display: flex;
align-items: center;
gap: 5px;
}
.radio-item input[type="radio"] {
width: auto;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-primary:hover {
background: #2980b9;
}
.btn-success {
background: #27ae60;
color: white;
}
.btn-success:hover {
background: #219653;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-group {
display: flex;
gap: 10px;
margin-top: 15px;
}
.status-section {
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 4px;
font-size: 14px;
}
.status-section h3 {
font-size: 16px;
margin-bottom: 10px;
color: #2c3e50;
}
.status-item {
margin-bottom: 8px;
padding: 8px;
background: white;
border-radius: 4px;
border-left: 3px solid #3498db;
}
.status-item.success {
border-left-color: #27ae60;
background: #e8f5e9;
}
.status-item.warning {
border-left-color: #f39c12;
background: #fff3e0;
}
.status-item.error {
border-left-color: #e74c3c;
background: #ffebee;
}
.ai-result {
background: #e3f2fd;
padding: 15px;
border-radius: 4px;
margin-top: 15px;
font-size: 14px;
}
.ai-result h4 {
margin-bottom: 10px;
color: #1976d2;
}
.ai-result p {
margin-bottom: 5px;
}
.ai-result .highlight {
font-weight: 600;
color: #d32f2f;
}
.config-info {
background: #f3e5f5;
padding: 15px;
border-radius: 4px;
margin-top: 15px;
font-size: 13px;
}
.config-info h4 {
margin-bottom: 10px;
color: #7b1fa2;
}
.config-item {
margin-bottom: 5px;
}
.config-item span {
font-weight: 500;
}
.loading {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #f3f3f3;
border-top: 2px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 8px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: 15px 20px;
background: #2c3e50;
color: white;
border-radius: 4px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
z-index: 1000;
max-width: 400px;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.notification.success {
background: #27ae60;
}
.notification.error {
background: #e74c3c;
}
.notification.warning {
background: #f39c12;
}
.file-upload-area {
border: 2px dashed #ddd;
border-radius: 4px;
padding: 20px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 15px;
}
.file-upload-area:hover {
border-color: #3498db;
background: #f0f8ff;
}
.file-upload-area.dragover {
border-color: #3498db;
background: #e3f2fd;
}
.file-upload-area p {
color: #666;
margin: 10px 0;
}
.file-upload-area i {
font-size: 24px;
color: #3498db;
}
.hidden {
display: none;
}
.stats {
display: flex;
gap: 20px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.stat-item {
background: white;
padding: 15px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
flex: 1;
min-width: 150px;
}
.stat-item h3 {
font-size: 14px;
color: #666;
margin-bottom: 5px;
}
.stat-item p {
font-size: 24px;
font-weight: 600;
color: #2c3e50;
}
.progress-bar {
width: 100%;
height: 4px;
background: #e0e0e0;
border-radius: 2px;
overflow: hidden;
margin-top: 10px;
}
.progress-fill {
height: 100%;
background: #3498db;
width: 0%;
transition: width 0.3s;
}
.section-divider {
height: 1px;
background: #e0e0e0;
margin: 20px 0;
}
.info-text {
font-size: 13px;
color: #666;
margin-top: 10px;
line-height: 1.5;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
margin-left: 5px;
}
.badge.success {
background: #e8f5e9;
color: #2e7d32;
}
.badge.warning {
background: #fff3e0;
color: #ef6c00;
}
.badge.error {
background: #ffebee;
color: #c62828;
}
.badge.info {
background: #e3f2fd;
color: #1565c0;
}
</style>
</head>
<body>
<header>
<div class="container">
<h1>Screen2Feishu - AI任务管理</h1>
</div>
</header>
<div class="container">
<!-- 统计信息 -->
<div class="stats" id="statsSection">
<div class="stat-item">
<h3>用户缓存</h3>
<p id="userCount">-</p>
</div>
<div class="stat-item">
<h3>最近联系人</h3>
<p id="recentCount">-</p>
</div>
<div class="stat-item">
<h3>缓存状态</h3>
<p id="cacheStatus">-</p>
</div>
</div>
<div class="main-content">
<!-- 左侧:图片上传 -->
<div class="card">
<h2>📸 图片上传分析</h2>
<div class="file-upload-area" id="uploadArea">
<i>📁</i>
<p>点击选择图片或拖拽到此处</p>
<p style="font-size: 12px; color: #999;">支持 JPG, PNG, GIF 等格式</p>
<input type="file" id="imageInput" accept="image/*" class="hidden">
</div>
<div class="form-group">
<label>文件名:</label>
<input type="text" id="fileName" readonly placeholder="未选择文件">
</div>
<div class="btn-group">
<button class="btn btn-success" id="uploadBtn" disabled>
<span id="uploadBtnText">上传并分析</span>
</button>
<button class="btn btn-primary" id="clearBtn">清空</button>
</div>
<div class="status-section" id="uploadStatus" style="display: none;">
<h3>上传状态</h3>
<div id="uploadStatusContent"></div>
</div>
<div class="ai-result" id="aiResult" style="display: none;">
<h4>AI分析结果</h4>
<div id="aiResultContent"></div>
</div>
</div>
<!-- 右侧:配置信息和状态 -->
<div class="card">
<h2>⚙️ 系统配置</h2>
<div class="config-info" id="configInfo">
<h4>当前配置</h4>
<div class="config-item">内存处理: <span id="configMemory">-</span></div>
<div class="config-item">用户匹配: <span id="configUserMatch">-</span></div>
<div class="config-item">随机匹配: <span id="configRandom">-</span></div>
<div class="config-item">缓存功能: <span id="configCache">-</span></div>
<div class="config-item">文件处理: <span id="configPostProcess">-</span></div>
</div>
<div class="section-divider"></div>
<h2>📊 操作说明</h2>
<div class="info-text">
<p><strong>1. 图片上传:</strong> 选择截图文件,系统会自动分析图片内容</p>
<p><strong>2. AI分析:</strong> 识别任务描述、优先级、发起人等信息</p>
<p><strong>3. 用户匹配:</strong> 自动匹配飞书用户,处理重名情况</p>
<p><strong>4. 写入飞书:</strong> 将分析结果写入飞书多维表格</p>
<p><strong>5. 结果反馈:</strong> 显示AI分析结果和操作状态</p>
</div>
<div class="section-divider"></div>
<h2>📝 状态日志</h2>
<div class="status-section" id="logSection">
<div id="logContent"></div>
</div>
</div>
</div>
</div>
<script>
// 全局变量
let currentFile = null;
let isUploading = false;
// DOM元素
const uploadArea = document.getElementById('uploadArea');
const imageInput = document.getElementById('imageInput');
const fileName = document.getElementById('fileName');
const uploadBtn = document.getElementById('uploadBtn');
const uploadBtnText = document.getElementById('uploadBtnText');
const clearBtn = document.getElementById('clearBtn');
const uploadStatus = document.getElementById('uploadStatus');
const uploadStatusContent = document.getElementById('uploadStatusContent');
const aiResult = document.getElementById('aiResult');
const aiResultContent = document.getElementById('aiResultContent');
const logContent = document.getElementById('logContent');
// 统计元素
const userCount = document.getElementById('userCount');
const recentCount = document.getElementById('recentCount');
const cacheStatus = document.getElementById('cacheStatus');
// 配置元素
const configMemory = document.getElementById('configMemory');
const configUserMatch = document.getElementById('configUserMatch');
const configRandom = document.getElementById('configRandom');
const configCache = document.getElementById('configCache');
const configPostProcess = document.getElementById('configPostProcess');
// 初始化
document.addEventListener('DOMContentLoaded', () => {
loadConfig();
loadUserCache();
setupEventListeners();
addLog('系统已启动,等待操作...');
});
// 设置事件监听器
function setupEventListeners() {
// 文件上传区域点击
uploadArea.addEventListener('click', () => {
if (!isUploading) {
imageInput.click();
}
});
// 文件选择变化
imageInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFileSelect(e.target.files[0]);
}
});
// 拖拽上传
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
const file = e.dataTransfer.files[0];
if (file.type.startsWith('image/')) {
handleFileSelect(file);
} else {
showNotification('请选择图片文件', 'error');
}
}
});
// 上传按钮
uploadBtn.addEventListener('click', uploadImage);
// 清空按钮
clearBtn.addEventListener('click', clearForm);
}
// 处理文件选择
function handleFileSelect(file) {
currentFile = file;
fileName.value = file.name;
uploadBtn.disabled = false;
addLog(`已选择文件: ${file.name} (${formatFileSize(file.size)})`);
showNotification('文件已选择,点击上传分析', 'success');
}
// 上传图片并分析
async function uploadImage() {
if (!currentFile || isUploading) {
return;
}
isUploading = true;
uploadBtn.disabled = true;
uploadBtnText.innerHTML = '<span class="loading"></span>分析中...';
// 显示上传状态
uploadStatus.style.display = 'block';
uploadStatusContent.innerHTML = '<div class="status-item">正在上传图片...</div>';
try {
// 创建FormData
const formData = new FormData();
formData.append('image', currentFile);
// 调用后端API
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
// 显示成功状态
uploadStatusContent.innerHTML = '<div class="status-item success">上传成功,正在分析...</div>';
addLog(`图片上传成功: ${currentFile.name}`);
// 显示AI分析结果
if (result.ai_result) {
displayAIResult(result.ai_result);
addLog('AI分析完成结果已显示');
}
// 显示成功消息
showNotification(result.message, 'success');
addLog(`任务已成功添加到飞书`);
// 清空文件选择
clearForm();
} else {
// 显示错误状态
uploadStatusContent.innerHTML = `<div class="status-item error">分析失败: ${result.error}</div>`;
addLog(`分析失败: ${result.error}`, 'error');
showNotification(`分析失败: ${result.error}`, 'error');
}
} catch (error) {
// 显示错误状态
uploadStatusContent.innerHTML = `<div class="status-item error">网络错误: ${error.message}</div>`;
addLog(`网络错误: ${error.message}`, 'error');
showNotification(`网络错误: ${error.message}`, 'error');
} finally {
isUploading = false;
uploadBtn.disabled = false;
uploadBtnText.textContent = '上传并分析';
}
}
// 显示AI分析结果
function displayAIResult(aiResult) {
let html = '';
// 任务描述
if (aiResult.task_description) {
html += `<p><strong>任务描述:</strong> ${aiResult.task_description}</p>`;
}
// 优先级
if (aiResult.priority) {
const priorityClass = getPriorityClass(aiResult.priority);
html += `<p><strong>优先级:</strong> <span class="badge ${priorityClass}">${aiResult.priority}</span></p>`;
}
// 状态
if (aiResult.status) {
html += `<p><strong>状态:</strong> ${aiResult.status}</p>`;
}
// 发起人
if (aiResult.initiator) {
html += `<p><strong>发起人:</strong> ${aiResult.initiator}</p>`;
}
// 部门
if (aiResult.department) {
html += `<p><strong>部门:</strong> ${aiResult.department}</p>`;
}
// 任务发起方(匹配结果)
if (aiResult["任务发起方"]) {
html += `<p><strong>匹配用户:</strong> ${aiResult["任务发起方"].id || '未匹配'}</p>`;
}
// 任务发起方.部门(匹配结果)
if (aiResult["任务发起方.部门"]) {
html += `<p><strong>匹配部门:</strong> ${aiResult["任务发起方.部门"]}</p>`;
}
// 日期
if (aiResult.start_date) {
html += `<p><strong>开始日期:</strong> ${aiResult.start_date}</p>`;
}
if (aiResult.due_date) {
html += `<p><strong>截止日期:</strong> ${aiResult.due_date}</p>`;
}
aiResultContent.innerHTML = html;
aiResult.style.display = 'block';
}
// 获取优先级样式类
function getPriorityClass(priority) {
const priorityMap = {
'紧急': 'error',
'较紧急': 'warning',
'一般': 'info',
'普通': 'success'
};
return priorityMap[priority] || 'info';
}
// 清空表单
function clearForm() {
currentFile = null;
imageInput.value = '';
fileName.value = '';
uploadBtn.disabled = true;
uploadStatus.style.display = 'none';
aiResult.style.display = 'none';
addLog('表单已清空');
}
// 加载配置
async function loadConfig() {
try {
const response = await fetch('/api/config');
const result = await response.json();
if (result.success) {
const config = result.config;
configMemory.textContent = config.memory_processing ? '启用' : '禁用';
configUserMatch.textContent = config.user_matching_enabled ? '启用' : '禁用';
configRandom.textContent = config.fallback_to_random ? '启用' : '禁用';
configCache.textContent = config.cache_enabled ? '启用' : '禁用';
configPostProcess.textContent = config.post_process;
addLog('配置加载成功');
} else {
addLog(`加载配置失败: ${result.error}`, 'error');
}
} catch (error) {
addLog(`加载配置失败: ${error.message}`, 'error');
}
}
// 加载用户缓存信息
async function loadUserCache() {
try {
const response = await fetch('/api/user_cache');
const result = await response.json();
if (result.success) {
const stats = result.stats;
userCount.textContent = stats.user_count || 0;
recentCount.textContent = stats.recent_contact_count || 0;
cacheStatus.textContent = stats.is_expired ? '已过期' : '有效';
if (stats.cache_age_hours > 0) {
cacheStatus.textContent += ` (${stats.cache_age_hours.toFixed(1)}小时)`;
}
addLog('用户缓存信息加载成功');
} else {
addLog(`加载用户缓存失败: ${result.error}`, 'error');
}
} catch (error) {
addLog(`加载用户缓存失败: ${error.message}`, 'error');
}
}
// 添加日志
function addLog(message, type = 'info') {
const timestamp = new Date().toLocaleTimeString();
const logEntry = document.createElement('div');
logEntry.className = `status-item ${type}`;
logEntry.innerHTML = `<strong>[${timestamp}]</strong> ${message}`;
logContent.insertBefore(logEntry, logContent.firstChild);
// 限制日志数量
while (logContent.children.length > 20) {
logContent.removeChild(logContent.lastChild);
}
}
// 显示通知
function showNotification(message, type = 'info') {
// 移除现有通知
const existingNotification = document.querySelector('.notification');
if (existingNotification) {
existingNotification.remove();
}
// 创建新通知
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
document.body.appendChild(notification);
// 3秒后自动移除
setTimeout(() => {
if (notification.parentNode) {
notification.remove();
}
}, 3000);
}
// 格式化文件大小
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// 定期更新用户缓存信息
setInterval(() => {
loadUserCache();
}, 30000); // 每30秒更新一次
</script>
</body>
</html>

5
tests/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""
单元测试模块
"""
# 空文件使tests目录成为Python包

54
utils/__init__.py Normal file
View File

@@ -0,0 +1,54 @@
"""
工具模块
"""
from .config_loader import (
load_config,
validate_config,
save_config,
create_default_config,
create_sample_config,
get_config_summary
)
from .logger import (
setup_logger,
get_logger,
log_function_call,
log_function_result,
log_error_with_context,
create_log_rotation_handler
)
from .notifier import (
send_notification,
send_notification_with_icon,
play_sound,
notify_task_processed,
notify_batch_result
)
__all__ = [
# 配置加载器
'load_config',
'validate_config',
'save_config',
'create_default_config',
'create_sample_config',
'get_config_summary',
# 日志记录器
'setup_logger',
'get_logger',
'log_function_call',
'log_function_result',
'log_error_with_context',
'create_log_rotation_handler',
# 通知器
'send_notification',
'send_notification_with_icon',
'play_sound',
'notify_task_processed',
'notify_batch_result'
]

200
utils/config_loader.py Normal file
View File

@@ -0,0 +1,200 @@
"""
配置文件加载和验证工具
"""
import yaml
from pathlib import Path
from typing import Dict, Any
def load_config(config_path: str) -> Dict[str, Any]:
"""
加载配置文件
Args:
config_path: 配置文件路径
Returns:
配置字典
Raises:
FileNotFoundError: 配置文件不存在
yaml.YAMLError: 配置文件格式错误
"""
config_file = Path(config_path)
if not config_file.exists():
raise FileNotFoundError(f"配置文件不存在: {config_path}")
try:
with open(config_file, 'r', encoding='utf-8') as f:
config = yaml.safe_load(f)
return config
except yaml.YAMLError as e:
raise yaml.YAMLError(f"配置文件格式错误: {str(e)}")
def validate_config(config: Dict[str, Any]) -> None:
"""
验证配置文件的完整性和有效性
Args:
config: 配置字典
Raises:
ValueError: 配置验证失败
"""
# 检查必需的顶级配置块
required_sections = ['ai', 'feishu', 'system']
for section in required_sections:
if section not in config:
raise ValueError(f"缺少必需的配置块: {section}")
# 验证AI配置
ai_config = config['ai']
if not ai_config.get('api_key'):
raise ValueError("AI配置缺少 api_key")
if not ai_config.get('base_url'):
raise ValueError("AI配置缺少 base_url")
if not ai_config.get('model'):
raise ValueError("AI配置缺少 model")
# 验证飞书配置
feishu_config = config['feishu']
if not feishu_config.get('app_id'):
raise ValueError("飞书配置缺少 app_id")
if not feishu_config.get('app_secret'):
raise ValueError("飞书配置缺少 app_secret")
if not feishu_config.get('app_token'):
raise ValueError("飞书配置缺少 app_token")
if not feishu_config.get('table_id'):
raise ValueError("飞书配置缺少 table_id")
# 验证系统配置
system_config = config['system']
if not system_config.get('watch_folder'):
raise ValueError("系统配置缺少 watch_folder")
# 验证后处理配置
post_process = system_config.get('post_process', 'keep')
valid_post_process = ['delete', 'move', 'keep']
if post_process not in valid_post_process:
raise ValueError(f"post_process 必须是 {valid_post_process} 之一,当前值: {post_process}")
if post_process == 'move' and not system_config.get('processed_folder'):
raise ValueError("当 post_process 为 'move' 时,必须配置 processed_folder")
# 验证路径格式
watch_folder = Path(system_config['watch_folder'])
if not watch_folder.is_absolute():
# 如果是相对路径,转换为绝对路径
watch_folder = Path.cwd() / watch_folder
# 检查监控文件夹是否存在(允许不存在,会在运行时创建)
if watch_folder.exists() and not watch_folder.is_dir():
raise ValueError(f"watch_folder 必须是一个目录: {watch_folder}")
# 如果配置了移动目标文件夹,检查其路径
if post_process == 'move':
processed_folder = Path(system_config['processed_folder'])
if not processed_folder.is_absolute():
processed_folder = Path.cwd() / processed_folder
if processed_folder.exists() and not processed_folder.is_dir():
raise ValueError(f"processed_folder 必须是一个目录: {processed_folder}")
def save_config(config: Dict[str, Any], config_path: str) -> None:
"""
保存配置文件
Args:
config: 配置字典
config_path: 配置文件路径
"""
config_file = Path(config_path)
# 确保目录存在
config_file.parent.mkdir(parents=True, exist_ok=True)
with open(config_file, 'w', encoding='utf-8') as f:
yaml.dump(config, f, default_flow_style=False, allow_unicode=True)
def create_default_config() -> Dict[str, Any]:
"""
创建默认配置
Returns:
默认配置字典
"""
return {
'ai': {
'api_key': 'sk-xxxxxxxxxxxxxxxxxxxxxxxx',
'base_url': 'https://api.siliconflow.cn/v1',
'model': 'gemini-2.5-pro'
},
'feishu': {
'app_id': 'cli_xxxxxxxxxxxx',
'app_secret': 'xxxxxxxxxxxxxxxxxxxxxxxx',
'app_token': 'bascnxxxxxxxxxxxxxxx',
'table_id': 'tblxxxxxxxxxxxx'
},
'system': {
'watch_folder': './monitor_images',
'post_process': 'move',
'processed_folder': './processed_images'
}
}
def create_sample_config(config_path: str = 'config.example.yaml') -> None:
"""
创建示例配置文件
Args:
config_path: 示例配置文件路径
"""
default_config = create_default_config()
save_config(default_config, config_path)
print(f"示例配置文件已创建: {config_path}")
def get_config_summary(config: Dict[str, Any]) -> str:
"""
获取配置摘要信息
Args:
config: 配置字典
Returns:
配置摘要字符串
"""
summary = []
summary.append("配置摘要:")
summary.append(f" AI模型: {config['ai']['model']}")
summary.append(f" AI服务: {config['ai']['base_url']}")
summary.append(f" 监控文件夹: {config['system']['watch_folder']}")
summary.append(f" 后处理方式: {config['system'].get('post_process', 'keep')}")
if config['system'].get('post_process') == 'move':
summary.append(f" 处理后文件夹: {config['system']['processed_folder']}")
return '\n'.join(summary)
if __name__ == "__main__":
# 测试配置验证
try:
# 创建示例配置
create_sample_config('config.example.yaml')
print("示例配置创建成功!")
# 加载并验证示例配置
config = load_config('config.example.yaml')
validate_config(config)
print("配置验证通过!")
print(get_config_summary(config))
except Exception as e:
print(f"配置测试失败: {e}")

185
utils/logger.py Normal file
View File

@@ -0,0 +1,185 @@
"""
日志记录工具
"""
import logging
import sys
from pathlib import Path
from typing import Optional
def setup_logger(
name: str = 'screen2feishu',
log_file: Optional[str] = None,
level: str = 'INFO',
format: str = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
) -> logging.Logger:
"""
设置日志记录器
Args:
name: 日志记录器名称
log_file: 日志文件路径如果为None则只输出到控制台
level: 日志级别 (DEBUG, INFO, WARNING, ERROR, CRITICAL)
format: 日志格式
Returns:
配置好的日志记录器
"""
# 创建日志记录器
logger = logging.getLogger(name)
# 避免重复配置
if logger.handlers:
return logger
# 设置日志级别
level_map = {
'DEBUG': logging.DEBUG,
'INFO': logging.INFO,
'WARNING': logging.WARNING,
'ERROR': logging.ERROR,
'CRITICAL': logging.CRITICAL
}
log_level = level_map.get(level.upper(), logging.INFO)
logger.setLevel(log_level)
# 创建格式化器
formatter = logging.Formatter(format, datefmt='%Y-%m-%d %H:%M:%S')
# 创建控制台处理器
# 在Windows系统上确保使用UTF-8编码输出
if sys.platform == 'win32':
import io
console_handler = logging.StreamHandler(io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8'))
else:
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# 创建文件处理器(如果指定了日志文件)
if log_file:
log_path = Path(log_file)
# 确保日志目录存在
log_path.parent.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(log_path, encoding='utf-8')
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
return logger
def get_logger(name: str = 'screen2feishu') -> logging.Logger:
"""
获取已配置的日志记录器
Args:
name: 日志记录器名称
Returns:
日志记录器
"""
return logging.getLogger(name)
def log_function_call(logger: logging.Logger, func_name: str, *args, **kwargs):
"""
记录函数调用日志
Args:
logger: 日志记录器
func_name: 函数名
*args: 位置参数
**kwargs: 关键字参数
"""
args_str = ', '.join(repr(arg) for arg in args)
kwargs_str = ', '.join(f'{k}={repr(v)}' for k, v in kwargs.items())
all_args = ', '.join(filter(None, [args_str, kwargs_str]))
logger.debug(f"调用函数: {func_name}({all_args})")
def log_function_result(logger: logging.Logger, func_name: str, result):
"""
记录函数返回结果日志
Args:
logger: 日志记录器
func_name: 函数名
result: 函数返回结果
"""
logger.debug(f"函数 {func_name} 返回: {repr(result)}")
def log_error_with_context(logger: logging.Logger, error: Exception, context: str = ""):
"""
记录错误日志,包含上下文信息
Args:
logger: 日志记录器
error: 异常对象
context: 错误上下文信息
"""
if context:
logger.error(f"{context}: {str(error)}")
else:
logger.error(f"错误: {str(error)}")
def create_log_rotation_handler(
log_file: str,
max_bytes: int = 10 * 1024 * 1024, # 10MB
backup_count: int = 5
) -> logging.Handler:
"""
创建支持日志轮转的文件处理器
Args:
log_file: 日志文件路径
max_bytes: 单个日志文件最大字节数
backup_count: 备份文件数量
Returns:
文件处理器
"""
from logging.handlers import RotatingFileHandler
log_path = Path(log_file)
log_path.parent.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(
log_file,
maxBytes=max_bytes,
backupCount=backup_count,
encoding='utf-8'
)
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
handler.setFormatter(formatter)
return handler
if __name__ == "__main__":
# 测试日志功能
logger = setup_logger(
name='test_logger',
log_file='test.log',
level='DEBUG'
)
logger.debug("这是一条调试信息")
logger.info("这是一条普通信息")
logger.warning("这是一条警告信息")
logger.error("这是一条错误信息")
# 测试函数调用日志
log_function_call(logger, "test_function", "arg1", "arg2", key="value")
log_function_result(logger, "test_function", {"result": "success"})
log_error_with_context(logger, Exception("测试错误"), "处理数据时")

306
utils/notifier.py Normal file
View File

@@ -0,0 +1,306 @@
"""
通知工具 - 支持桌面通知、声音提示等
"""
import platform
import subprocess
import logging
from typing import Optional
def send_notification(title: str, message: str, urgency: str = "normal") -> bool:
"""
发送桌面通知
Args:
title: 通知标题
message: 通知内容
urgency: 通知紧急程度 (low, normal, high)
Returns:
是否成功发送
"""
system = platform.system()
try:
if system == "Windows":
# Windows 使用 PowerShell 发送通知
ps_script = f"""
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] > $null
$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
$template.SelectSingleNode("//text[@id='1']").AppendChild($template.CreateTextNode('{title}')) > $null
$template.SelectSingleNode("//text[@id='2']").AppendChild($template.CreateTextNode('{message}')) > $null
$toast = [Windows.UI.Notifications.ToastNotification]::new($template)
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Screen2Feishu").Show($toast)
"""
# 使用 PowerShell 执行
subprocess.run(
["powershell", "-Command", ps_script],
capture_output=True,
timeout=5
)
return True
elif system == "Darwin": # macOS
# macOS 使用 osascript 发送通知
script = f'display notification "{message}" with title "{title}"'
subprocess.run(
["osascript", "-e", script],
capture_output=True,
timeout=5
)
return True
elif system == "Linux":
# Linux 使用 notify-send
try:
subprocess.run(
["notify-send", "-u", urgency, title, message],
capture_output=True,
timeout=5
)
return True
except FileNotFoundError:
# 如果 notify-send 不可用,尝试使用 zenity
try:
subprocess.run(
["zenity", "--info", "--title", title, "--text", message],
capture_output=True,
timeout=5
)
return True
except FileNotFoundError:
return False
else:
return False
except Exception as e:
logging.error(f"发送通知失败: {str(e)}")
return False
def send_notification_with_icon(title: str, message: str, icon_path: Optional[str] = None) -> bool:
"""
发送带图标的桌面通知
Args:
title: 通知标题
message: 通知内容
icon_path: 图标文件路径
Returns:
是否成功发送
"""
system = platform.system()
try:
if system == "Linux" and icon_path:
# Linux 支持图标
subprocess.run(
["notify-send", "-u", "normal", "-i", icon_path, title, message],
capture_output=True,
timeout=5
)
return True
else:
# 其他系统使用普通通知
return send_notification(title, message)
except Exception as e:
logging.error(f"发送带图标通知失败: {str(e)}")
return False
def play_sound(sound_type: str = "success") -> bool:
"""
播放系统声音
Args:
sound_type: 声音类型 (success, error, warning, info)
Returns:
是否成功播放
"""
system = platform.system()
try:
if system == "Windows":
# Windows 使用 PowerShell 播放系统声音
sound_map = {
"success": "SystemAsterisk",
"error": "SystemHand",
"warning": "SystemExclamation",
"info": "SystemDefault"
}
sound = sound_map.get(sound_type, "SystemDefault")
ps_script = f'[System.Media.SystemSounds]::{sound}.Play()'
subprocess.run(
["powershell", "-Command", ps_script],
capture_output=True,
timeout=5
)
return True
elif system == "Darwin": # macOS
# macOS 使用 afplay 播放系统声音
sound_map = {
"success": "/System/Library/Sounds/Purr.aiff",
"error": "/System/Library/Sounds/Basso.aiff",
"warning": "/System/Library/Sounds/Funk.aiff",
"info": "/System/Library/Sounds/Glass.aiff"
}
sound_file = sound_map.get(sound_type, "/System/Library/Sounds/Glass.aiff")
# 异步播放声音
subprocess.Popen(
["afplay", sound_file],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
return True
elif system == "Linux":
# Linux 使用 paplay 或 aplay 播放声音
# 这里使用简单的 beep 声音
try:
# 尝试使用 beep
subprocess.run(
["beep"],
capture_output=True,
timeout=5
)
return True
except FileNotFoundError:
# 如果 beep 不可用,尝试使用 paplay
try:
# 创建临时的声音文件
import tempfile
import wave
import struct
# 创建简单的正弦波声音
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
# 写入 WAV 文件头
sample_rate = 44100
duration = 0.2
num_samples = int(sample_rate * duration)
wav_file = wave.open(f.name, 'w')
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(sample_rate)
# 生成正弦波
for i in range(num_samples):
value = int(32767 * 0.3 * (1 if i < num_samples // 2 else 0))
wav_file.writeframes(struct.pack('<h', value))
wav_file.close()
# 播放声音
subprocess.run(
["paplay", f.name],
capture_output=True,
timeout=5
)
# 删除临时文件
import os
os.unlink(f.name)
return True
except Exception:
return False
else:
return False
except Exception as e:
logging.error(f"播放声音失败: {str(e)}")
return False
def notify_task_processed(success: bool, file_name: str, count: int = 1) -> bool:
"""
通知任务处理结果
Args:
success: 是否成功
file_name: 文件名
count: 处理数量
Returns:
是否成功发送通知
"""
if success:
title = " 任务处理成功"
message = f"已成功处理 {count} 个文件: {file_name}"
sound_type = "success"
else:
title = " 任务处理失败"
message = f"处理文件失败: {file_name}"
sound_type = "error"
# 发送桌面通知
notification_sent = send_notification(title, message)
# 播放声音提示
sound_played = play_sound(sound_type)
return notification_sent or sound_played
def notify_batch_result(results: dict) -> bool:
"""
通知批量处理结果
Args:
results: 处理结果字典 {文件名: 是否成功}
Returns:
是否成功发送通知
"""
success_count = sum(1 for v in results.values() if v)
total_count = len(results)
if success_count == total_count:
title = " 批量处理完成"
message = f"所有 {total_count} 个文件处理成功"
sound_type = "success"
elif success_count == 0:
title = " 批量处理失败"
message = f"所有 {total_count} 个文件处理失败"
sound_type = "error"
else:
title = "⚠️ 批量处理部分成功"
message = f"{success_count}/{total_count} 个文件处理成功"
sound_type = "warning"
# 发送桌面通知
notification_sent = send_notification(title, message)
# 播放声音提示
sound_played = play_sound(sound_type)
return notification_sent or sound_played
if __name__ == "__main__":
# 测试通知功能
import logging
logging.basicConfig(level=logging.INFO)
print("测试桌面通知...")
success = send_notification("测试通知", "这是一个测试通知消息")
print(f"通知发送: {'成功' if success else '失败'}")
print("测试声音播放...")
success = play_sound("success")
print(f"声音播放: {'成功' if success else '失败'}")
print("测试任务处理通知...")
success = notify_task_processed(True, "test_image.png")
print(f"任务通知: {'成功' if success else '失败'}")

211
utils/user_cache.py Normal file
View File

@@ -0,0 +1,211 @@
import json
import time
import random
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import logging
class UserCacheManager:
"""用户缓存管理器,用于管理飞书用户信息和最近联系人"""
def __init__(self, cache_file: str = "./data/user_cache.json"):
"""
初始化用户缓存管理器
Args:
cache_file: 缓存文件路径
"""
self.cache_file = Path(cache_file)
self.cache_file.parent.mkdir(parents=True, exist_ok=True)
# 缓存数据结构
self.users: List[Dict] = [] # 用户列表
self.recent_contacts: List[str] = [] # 最近联系人名字列表
self.last_update_time: float = 0 # 最后更新时间
# 加载缓存
self._load_cache()
def _load_cache(self):
"""从文件加载缓存"""
try:
if self.cache_file.exists():
with open(self.cache_file, 'r', encoding='utf-8') as f:
data = json.load(f)
self.users = data.get('users', [])
self.recent_contacts = data.get('recent_contacts', [])
self.last_update_time = data.get('last_update_time', 0)
logging.info(f"已加载用户缓存,共 {len(self.users)} 个用户")
else:
logging.info("未找到用户缓存文件,将创建新的缓存")
except Exception as e:
logging.error(f"加载用户缓存失败: {str(e)}")
self.users = []
self.recent_contacts = []
self.last_update_time = 0
def _save_cache(self):
"""保存缓存到文件"""
try:
data = {
'users': self.users,
'recent_contacts': self.recent_contacts,
'last_update_time': time.time()
}
with open(self.cache_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logging.info(f"已保存用户缓存到 {self.cache_file}")
except Exception as e:
logging.error(f"保存用户缓存失败: {str(e)}")
def update_users(self, users: List[Dict]):
"""
更新用户列表
Args:
users: 飞书用户列表,每个用户包含 name, user_id 等字段
"""
self.users = users
self.last_update_time = time.time()
self._save_cache()
logging.info(f"已更新用户列表,共 {len(users)} 个用户")
def add_recent_contact(self, name: str):
"""
添加最近联系人
Args:
name: 联系人姓名
"""
if not name or name.strip() == "":
return
name = name.strip()
# 如果已存在,先移除
if name in self.recent_contacts:
self.recent_contacts.remove(name)
# 添加到列表开头
self.recent_contacts.insert(0, name)
# 限制列表长度最多保留50个
if len(self.recent_contacts) > 50:
self.recent_contacts = self.recent_contacts[:50]
self._save_cache()
logging.debug(f"已添加最近联系人: {name}")
def match_user_by_name(self, name: str) -> Optional[Dict]:
"""
根据名字匹配用户
Args:
name: 要匹配的名字
Returns:
匹配的用户信息如果未找到则返回None
"""
if not name or not self.users:
return None
name = name.strip()
# 精确匹配
for user in self.users:
if user.get('name') == name:
return user
# 模糊匹配(包含关系)
for user in self.users:
user_name = user.get('name', '')
if user_name and (name in user_name or user_name in name):
return user
return None
def get_random_recent_contact(self) -> Optional[str]:
"""
从最近联系人中随机选择一个
Returns:
随机选择的联系人名字如果没有则返回None
"""
if not self.recent_contacts:
return None
# 随机选择一个
return random.choice(self.recent_contacts)
def get_user_suggestions(self, name: str) -> List[Dict]:
"""
获取名字相似的用户建议
Args:
name: 要匹配的名字
Returns:
相似的用户列表
"""
if not name or not self.users:
return []
name = name.strip().lower()
suggestions = []
for user in self.users:
user_name = user.get('name', '')
user_name_lower = user_name.lower()
# 计算相似度(简单的包含关系)
if name in user_name_lower or user_name_lower in name:
suggestions.append(user)
elif name[0] == user_name_lower[0] if user_name_lower else False:
suggestions.append(user)
# 限制返回数量
return suggestions[:5]
def is_cache_expired(self, max_age_hours: int = 24) -> bool:
"""
检查缓存是否过期
Args:
max_age_hours: 最大缓存时间(小时)
Returns:
是否过期
"""
if self.last_update_time == 0:
return True
age_hours = (time.time() - self.last_update_time) / 3600
return age_hours > max_age_hours
def clear_cache(self):
"""清除缓存"""
self.users = []
self.recent_contacts = []
self.last_update_time = 0
self._save_cache()
logging.info("已清除用户缓存")
def get_cache_stats(self) -> Dict:
"""
获取缓存统计信息
Returns:
缓存统计信息
"""
age_hours = 0
if self.last_update_time > 0:
age_hours = (time.time() - self.last_update_time) / 3600
return {
'user_count': len(self.users),
'recent_contact_count': len(self.recent_contacts),
'last_update_time': self.last_update_time,
'cache_age_hours': round(age_hours, 2),
'is_expired': self.is_cache_expired()
}

452
web_app.py Normal file
View File

@@ -0,0 +1,452 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Screen2Feishu Web应用
结合前端页面与后端功能
"""
import os
import sys
import json
import time
import base64
import logging
from pathlib import Path
from datetime import datetime
from typing import Dict, Optional, List
# 添加项目根目录到Python路径
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
from flask import Flask, render_template, request, jsonify, send_from_directory
from flask_cors import CORS
from werkzeug.utils import secure_filename
from services.ai_service import AIService
from services.feishu_service import FeishuService
from utils.config_loader import load_config, validate_config
from utils.user_cache import UserCacheManager
from utils.logger import setup_logger
# 初始化Flask应用
app = Flask(__name__,
static_folder='static',
template_folder='templates')
CORS(app)
# 配置上传文件大小限制
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB
# 全局变量
config = None
ai_service = None
feishu_service = None
user_cache = None
logger = None
def init_app():
"""初始化应用"""
global config, ai_service, feishu_service, user_cache, logger
# 加载配置
try:
config = load_config("config.yaml")
validate_config(config)
logger = setup_logger(config['system']['log_level'], config['system']['log_file'])
logger.info("配置加载成功")
except Exception as e:
print(f"配置加载失败: {str(e)}")
sys.exit(1)
# 初始化服务
try:
ai_service = AIService(config['ai'])
feishu_service = FeishuService(config['feishu'])
# 初始化用户缓存
cache_file = config['system']['user_matching']['cache_file']
user_cache = UserCacheManager(cache_file)
logger.info("服务初始化成功")
except Exception as e:
logger.error(f"服务初始化失败: {str(e)}")
sys.exit(1)
# 创建必要的目录
Path("monitor_images").mkdir(exist_ok=True)
Path("processed_images").mkdir(exist_ok=True)
Path("data").mkdir(exist_ok=True)
# 路由定义
@app.route('/')
def index():
"""首页 - 返回前端HTML页面"""
return render_template('index.html')
@app.route('/api/upload', methods=['POST'])
def upload_image():
"""上传图片并分析"""
try:
if 'image' not in request.files:
return jsonify({'success': False, 'error': '没有上传文件'}), 400
file = request.files['image']
if file.filename == '':
return jsonify({'success': False, 'error': '没有选择文件'}), 400
if not file.content_type.startswith('image/'):
return jsonify({'success': False, 'error': '文件不是图片'}), 400
# 保存图片到monitor_images目录
filename = secure_filename(file.filename)
timestamp = int(time.time())
filename = f"screenshot-{timestamp}-{filename}"
filepath = Path("monitor_images") / filename
file.save(filepath)
logger.info(f"上传图片: {filename}")
# 检查是否启用内存处理
memory_processing = config['system'].get('memory_processing', False)
if memory_processing:
# 内存处理模式
with open(filepath, 'rb') as f:
image_bytes = f.read()
# AI分析图片
ai_result = ai_service.analyze_image_from_bytes(image_bytes, filename)
if not ai_result:
return jsonify({'success': False, 'error': 'AI分析失败'}), 500
else:
# 文件处理模式
ai_result = ai_service.analyze_image(str(filepath))
if not ai_result:
return jsonify({'success': False, 'error': 'AI分析失败'}), 500
# 处理任务发起方
initiator_name = ai_result.get("initiator", "")
if initiator_name and config['system']['user_matching']['enabled']:
# 尝试匹配用户
user_match = _match_initiator(initiator_name)
if user_match:
if user_match.get('matched'):
# 成功匹配到用户ID
ai_result["任务发起方"] = {"id": user_match['user_id']}
logger.info(f"成功匹配任务发起方: {initiator_name} -> {user_match['user_name']}")
else:
# 未匹配到用户,添加待确认标记
ai_result["task_description"] = f"[待确认发起人] {ai_result['task_description']}"
ai_result["任务发起方.部门"] = f"待确认: {initiator_name}"
logger.warning(f"未匹配到用户: {initiator_name},标记为待确认")
# 添加到最近联系人
if user_cache:
user_cache.add_recent_contact(initiator_name)
else:
# 匹配失败,使用随机选择或留空
if config['system']['user_matching']['fallback_to_random'] and user_cache:
random_contact = user_cache.get_random_recent_contact()
if random_contact:
ai_result["task_description"] = f"[随机匹配] {ai_result['task_description']}"
ai_result["任务发起方.部门"] = f"随机: {random_contact}"
logger.info(f"随机匹配任务发起方: {random_contact}")
else:
ai_result["task_description"] = f"[待确认发起人] {ai_result['task_description']}"
logger.warning(f"无最近联系人可用,标记为待确认: {initiator_name}")
else:
ai_result["task_description"] = f"[待确认发起人] {ai_result['task_description']}"
logger.warning(f"未匹配到用户且未启用随机匹配: {initiator_name}")
# 处理部门信息
if "department" in ai_result and ai_result["department"]:
if "任务发起方.部门" not in ai_result:
ai_result["任务发起方.部门"] = ai_result["department"]
# 写入飞书
success = feishu_service.add_task(ai_result)
if success:
# 后处理文件
post_process = config['system'].get('post_process', 'keep')
if post_process == 'delete':
filepath.unlink()
logger.info(f"已删除文件: {filename}")
elif post_process == 'move':
processed_folder = Path(config['system']['processed_folder'])
processed_folder.mkdir(exist_ok=True)
target_path = processed_folder / filename
filepath.rename(target_path)
logger.info(f"已移动文件到: {target_path}")
return jsonify({
'success': True,
'message': '任务已成功添加到飞书',
'ai_result': ai_result
})
else:
return jsonify({'success': False, 'error': '写入飞书失败'}), 500
except Exception as e:
logger.error(f"处理图片失败: {str(e)}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/tasks', methods=['GET'])
def get_tasks():
"""获取任务列表"""
try:
# 这里可以添加从飞书获取任务列表的逻辑
# 暂时返回空列表
return jsonify({'success': True, 'tasks': []})
except Exception as e:
logger.error(f"获取任务列表失败: {str(e)}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/user_cache', methods=['GET'])
def get_user_cache():
"""获取用户缓存信息"""
try:
if not user_cache:
return jsonify({'success': False, 'error': '用户缓存未初始化'}), 500
stats = user_cache.get_cache_stats()
return jsonify({'success': True, 'stats': stats})
except Exception as e:
logger.error(f"获取用户缓存信息失败: {str(e)}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/config', methods=['GET'])
def get_config():
"""获取配置信息"""
try:
config_info = {
'memory_processing': config['system'].get('memory_processing', False),
'user_matching_enabled': config['system']['user_matching']['enabled'],
'fallback_to_random': config['system']['user_matching']['fallback_to_random'],
'cache_enabled': config['system']['user_matching']['cache_enabled'],
'post_process': config['system'].get('post_process', 'keep')
}
return jsonify({'success': True, 'config': config_info})
except Exception as e:
logger.error(f"获取配置信息失败: {str(e)}")
return jsonify({'success': False, 'error': str(e)}), 500
def _match_initiator(initiator_name: str) -> Optional[Dict]:
"""匹配任务发起人"""
if not initiator_name or not config['system']['user_matching']['enabled']:
return None
# 确保用户缓存已初始化
if not user_cache:
return None
# 检查缓存是否过期,如果过期则重新获取用户列表
if user_cache.is_cache_expired(max_age_hours=24):
logger.info("用户缓存已过期,重新获取用户列表")
users = feishu_service._get_user_list()
if users:
user_cache.update_users(users)
# 尝试匹配用户
matched_user = user_cache.match_user_by_name(initiator_name)
if matched_user:
return {
'matched': True,
'user_id': matched_user.get('user_id'),
'user_name': matched_user.get('name')
}
else:
return {
'matched': False,
'user_name': initiator_name
}
@app.route('/api/screenshot', methods=['POST'])
def capture_screenshot():
"""截图上传接口"""
try:
if 'screenshot' not in request.files:
return jsonify({'success': False, 'error': '没有上传文件'}), 400
file = request.files['screenshot']
if file.filename == '':
return jsonify({'success': False, 'error': '没有选择文件'}), 400
# 保存截图
filename = secure_filename(file.filename)
timestamp = int(time.time())
filename = f"screenshot-{timestamp}-{filename}"
filepath = Path("monitor_images") / filename
file.save(filepath)
logger.info(f"接收截图: {filename}")
# 调用分析接口
with open(filepath, 'rb') as f:
image_bytes = f.read()
# AI分析图片
ai_result = ai_service.analyze_image_from_bytes(image_bytes, filename)
if not ai_result:
return jsonify({'success': False, 'error': 'AI分析失败'}), 500
# 处理任务发起方
initiator_name = ai_result.get("initiator", "")
if initiator_name and config['system']['user_matching']['enabled']:
user_match = _match_initiator(initiator_name)
if user_match:
if user_match.get('matched'):
ai_result["任务发起方"] = {"id": user_match['user_id']}
logger.info(f"成功匹配任务发起方: {initiator_name} -> {user_match['user_name']}")
else:
ai_result["task_description"] = f"[待确认发起人] {ai_result['task_description']}"
ai_result["任务发起方.部门"] = f"待确认: {initiator_name}"
logger.warning(f"未匹配到用户: {initiator_name},标记为待确认")
if user_cache:
user_cache.add_recent_contact(initiator_name)
else:
if config['system']['user_matching']['fallback_to_random'] and user_cache:
random_contact = user_cache.get_random_recent_contact()
if random_contact:
ai_result["task_description"] = f"[随机匹配] {ai_result['task_description']}"
ai_result["任务发起方.部门"] = f"随机: {random_contact}"
logger.info(f"随机匹配任务发起方: {random_contact}")
else:
ai_result["task_description"] = f"[待确认发起人] {ai_result['task_description']}"
logger.warning(f"无最近联系人可用,标记为待确认: {initiator_name}")
else:
ai_result["task_description"] = f"[待确认发起人] {ai_result['task_description']}"
logger.warning(f"未匹配到用户且未启用随机匹配: {initiator_name}")
# 处理部门信息
if "department" in ai_result and ai_result["department"]:
if "任务发起方.部门" not in ai_result:
ai_result["任务发起方.部门"] = ai_result["department"]
# 写入飞书
success = feishu_service.add_task(ai_result)
if success:
# 后处理文件
post_process = config['system'].get('post_process', 'keep')
if post_process == 'delete':
filepath.unlink()
logger.info(f"已删除文件: {filename}")
elif post_process == 'move':
processed_folder = Path(config['system']['processed_folder'])
processed_folder.mkdir(exist_ok=True)
target_path = processed_folder / filename
filepath.rename(target_path)
logger.info(f"已移动文件到: {target_path}")
return jsonify({
'success': True,
'message': '任务已成功添加到飞书',
'ai_result': ai_result
})
else:
return jsonify({'success': False, 'error': '写入飞书失败'}), 500
except Exception as e:
logger.error(f"处理截图失败: {str(e)}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/monitor', methods=['POST'])
def monitor_upload():
"""监控文件夹上传接口"""
try:
if 'file' not in request.files:
return jsonify({'success': False, 'error': '没有上传文件'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'success': False, 'error': '没有选择文件'}), 400
# 保存文件到monitor_images
filename = secure_filename(file.filename)
filepath = Path("monitor_images") / filename
file.save(filepath)
logger.info(f"接收监控文件: {filename}")
# 调用分析接口
with open(filepath, 'rb') as f:
image_bytes = f.read()
# AI分析图片
ai_result = ai_service.analyze_image_from_bytes(image_bytes, filename)
if not ai_result:
return jsonify({'success': False, 'error': 'AI分析失败'}), 500
# 处理任务发起方
initiator_name = ai_result.get("initiator", "")
if initiator_name and config['system']['user_matching']['enabled']:
user_match = _match_initiator(initiator_name)
if user_match:
if user_match.get('matched'):
ai_result["任务发起方"] = {"id": user_match['user_id']}
logger.info(f"成功匹配任务发起方: {initiator_name} -> {user_match['user_name']}")
else:
ai_result["task_description"] = f"[待确认发起人] {ai_result['task_description']}"
ai_result["任务发起方.部门"] = f"待确认: {initiator_name}"
logger.warning(f"未匹配到用户: {initiator_name},标记为待确认")
if user_cache:
user_cache.add_recent_contact(initiator_name)
else:
if config['system']['user_matching']['fallback_to_random'] and user_cache:
random_contact = user_cache.get_random_recent_contact()
if random_contact:
ai_result["task_description"] = f"[随机匹配] {ai_result['task_description']}"
ai_result["任务发起方.部门"] = f"随机: {random_contact}"
logger.info(f"随机匹配任务发起方: {random_contact}")
else:
ai_result["task_description"] = f"[待确认发起人] {ai_result['task_description']}"
logger.warning(f"无最近联系人可用,标记为待确认: {initiator_name}")
else:
ai_result["task_description"] = f"[待确认发起人] {ai_result['task_description']}"
logger.warning(f"未匹配到用户且未启用随机匹配: {initiator_name}")
# 处理部门信息
if "department" in ai_result and ai_result["department"]:
if "任务发起方.部门" not in ai_result:
ai_result["任务发起方.部门"] = ai_result["department"]
# 写入飞书
success = feishu_service.add_task(ai_result)
if success:
# 后处理文件
post_process = config['system'].get('post_process', 'keep')
if post_process == 'delete':
filepath.unlink()
logger.info(f"已删除文件: {filename}")
elif post_process == 'move':
processed_folder = Path(config['system']['processed_folder'])
processed_folder.mkdir(exist_ok=True)
target_path = processed_folder / filename
filepath.rename(target_path)
logger.info(f"已移动文件到: {target_path}")
return jsonify({
'success': True,
'message': '任务已成功添加到飞书',
'ai_result': ai_result
})
else:
return jsonify({'success': False, 'error': '写入飞书失败'}), 500
except Exception as e:
logger.error(f"处理监控文件失败: {str(e)}")
return jsonify({'success': False, 'error': str(e)}), 500
if __name__ == '__main__':
# 初始化应用
init_app()
# 启动Web服务器
logger.info("启动Web服务器...")
app.run(host='0.0.0.0', port=5000, debug=False)

56
优先级修改说明.md Normal file
View File

@@ -0,0 +1,56 @@
# Screen2Feishu 优先级修改说明
## 修改内容
将任务优先级选项从原来的4个选项修改为新的4个选项
### 修改前
- 重要紧急
- 重要不紧急
- 紧急不重要
- 不重要不紧急
### 修改后
- 紧急
- 较紧急
- 一般
- 普通
## 修改文件
### 1. AI服务 ([`services/ai_service.py`](services/ai_service.py))
- **AI提示词** (第71-95行): 修改了提示词中的优先级选项
- **验证逻辑** (第123-131行): 修改了优先级验证逻辑
- **重试验证** (第148-156行): 修改了重试时的优先级验证逻辑
### 2. 飞书服务 ([`services/feishu_service.py`](services/feishu_service.py))
- **验证逻辑** (第139-149行): 修改了优先级验证逻辑
- **默认值** (第158行): 修改了默认优先级值
- **测试数据** (第468行): 修改了测试数据中的优先级值
## 测试验证
运行`test_priority.py`测试脚本,结果显示:
- ✅ AI服务优先级验证通过
- ✅ 飞书服务优先级验证通过
- ✅ 旧优先级选项正确被拒绝
- ✅ AI提示词已包含新的优先级选项
- ✅ AI提示词已移除旧的优先级选项
## 影响范围
1. **AI识别**: AI将根据新的优先级选项进行识别和分类
2. **飞书表格**: 飞书表格中的"重要紧急程度"字段将显示新的优先级选项
3. **验证逻辑**: 系统将只接受新的优先级选项,拒绝旧的选项
## 注意事项
1. **飞书表格配置**: 需要确保飞书表格中的"重要紧急程度"字段包含新的选项
2. **历史数据**: 已有的历史数据可能需要手动更新为新的优先级选项
3. **AI训练**: AI模型需要根据新的优先级选项进行重新训练或调整
## 后续工作
1. 更新飞书表格的选项配置
2. 通知用户优先级选项的变更
3. 考虑提供数据迁移工具,将旧的优先级选项转换为新的选项

288
项目结构说明.md Normal file
View File

@@ -0,0 +1,288 @@
# Screen2Feishu 项目结构说明
## 项目目录结构
```
Screen2Feishu/
├── main.py # 原始命令行程序入口
├── web_app.py # Web应用入口
├── start_web_app.py # Web应用启动器
├── config.yaml # 配置文件
├── config.example.yaml # 配置文件示例
├── requirements.txt # Python依赖
├── README.md # 项目说明
├── Web应用使用说明.md # Web应用使用说明
├── 项目结构说明.md # 本文件
├── 优化设计方案.md # 优化设计方案
├── 优化功能说明.md # 优化功能说明
├── 优化总结_更新版.md # 优化总结
├── BUG修复总结.md # BUG修复说明
├── 优先级修改说明.md # 优先级修改说明
├── test_priority.py # 优先级测试脚本
├── app.log # 应用日志文件
├── data/ # 数据目录
│ ├── user_cache.json # 用户缓存文件
│ └── test_user_cache.json # 测试缓存文件
├── monitor_images/ # 监控图片目录(待处理)
├── processed_images/ # 已处理图片目录
├── services/ # 服务层
│ ├── __init__.py
│ ├── ai_service.py # AI服务
│ └── feishu_service.py # 飞书服务
├── templates/ # Web模板目录
│ └── index.html # Web界面模板
├── utils/ # 工具层
│ ├── __init__.py
│ ├── config_loader.py # 配置加载器
│ ├── logger.py # 日志工具
│ ├── notifier.py # 通知工具
│ └── user_cache.py # 用户缓存管理器
└── tests/ # 测试目录
├── __init__.py
└── test_config_loader.py # 配置加载器测试
```
## 核心文件说明
### 1. 主程序文件
#### main.py
- **功能**:原始命令行程序入口
- **用途**:监控文件夹,自动处理图片并写入飞书
- **使用方式**`python main.py`
#### web_app.py
- **功能**Web应用入口
- **用途**提供Web界面和API接口
- **使用方式**`python web_app.py`
#### start_web_app.py
- **功能**Web应用启动器
- **用途**检查环境并启动Web应用
- **使用方式**`python start_web_app.py`
### 2. 配置文件
#### config.yaml
- **功能**:应用配置文件
- **内容**
- AI服务配置API密钥、模型等
- 飞书服务配置应用ID、密钥、表格ID等
- 系统配置(文件夹路径、处理策略等)
- 用户匹配配置(启用状态、缓存路径等)
#### config.example.yaml
- **功能**:配置文件示例
- **用途**:作为配置文件模板
### 3. 服务层
#### services/ai_service.py
- **功能**AI服务
- **主要类**`AIService`
- **主要方法**
- `analyze_image()`:分析图片文件
- `analyze_image_from_bytes()`:分析内存中的图片数据
- `_build_prompt()`构建AI提示词
- `_parse_ai_response()`解析AI响应
#### services/feishu_service.py
- **功能**:飞书服务
- **主要类**`FeishuService`
- **主要方法**
- `add_task()`:添加任务到飞书
- `_match_initiator()`:匹配任务发起人
- `_get_user_list()`:获取飞书用户列表
- `_build_fields()`:构建飞书字段数据
### 4. 工具层
#### utils/user_cache.py
- **功能**:用户缓存管理器
- **主要类**`UserCacheManager`
- **主要方法**
- `match_user_by_name()`:根据名字匹配用户
- `add_recent_contact()`:添加最近联系人
- `get_random_recent_contact()`:随机选择最近联系人
- `update_users()`:更新用户列表
#### utils/config_loader.py
- **功能**:配置加载器
- **主要函数**
- `load_config()`:加载配置文件
- `validate_config()`:验证配置
#### utils/logger.py
- **功能**:日志工具
- **主要函数**
- `setup_logger()`:设置日志记录器
#### utils/notifier.py
- **功能**:通知工具
- **主要函数**
- `send_notification()`:发送桌面通知
### 5. Web界面
#### templates/index.html
- **功能**Web界面模板
- **主要功能**
- 任务添加表单
- 图片上传功能
- 任务列表展示
- 筛选和搜索功能
- 编辑和删除功能
- 通知权限管理
### 6. 测试文件
#### test_priority.py
- **功能**:优先级测试脚本
- **用途**:验证优先级修改是否正确
## 数据流
### 1. 命令行模式main.py
```
监控文件夹 → 检测新图片 → AI分析 → 用户匹配 → 飞书写入 → 后处理
```
### 2. Web应用模式web_app.py
```
用户上传图片 → Web服务器接收 → AI分析 → 用户匹配 → 飞书写入 → 返回结果
```
## 配置说明
### AI服务配置
```yaml
ai:
api_key: "API密钥"
base_url: "API基础URL"
model: "模型名称"
```
### 飞书服务配置
```yaml
feishu:
app_id: "应用ID"
app_secret: "应用密钥"
app_token: "多维表格Token"
table_id: "数据表ID"
```
### 系统配置
```yaml
system:
watch_folder: "监控文件夹路径"
post_process: "文件处理策略(keep/delete/move)"
processed_folder: "已处理文件夹路径"
log_level: "日志级别"
log_file: "日志文件路径"
memory_processing: "是否启用内存处理"
```
### 用户匹配配置
```yaml
system:
user_matching:
enabled: "是否启用用户匹配"
fallback_to_random: "是否启用随机匹配回退"
cache_enabled: "是否启用缓存"
cache_file: "缓存文件路径"
```
## 使用方式
### 1. 命令行模式
```bash
# 安装依赖
pip install -r requirements.txt
# 配置config.yaml
cp config.example.yaml config.yaml
# 编辑config.yaml填入正确的配置
# 启动程序
python main.py
```
### 2. Web应用模式
```bash
# 安装依赖
pip install -r requirements.txt
# 配置config.yaml
cp config.example.yaml config.yaml
# 编辑config.yaml填入正确的配置
# 启动Web应用
python start_web_app.py
# 或者直接启动
python web_app.py
# 访问界面
# 打开浏览器,访问 http://localhost:5000
```
## 优化功能
### 1. 任务发起方识别优化
- AI提示词增强明确指示识别发件人/发送者
- 用户匹配机制,自动匹配飞书用户
- 人工评判机制,重名时提供备选方案
- 最近联系人缓存,提高匹配效率
### 2. 图片处理优化
- 内存处理支持,避免保存图片文件
- 文件处理策略,可配置保留/删除/移动
- 存储优化减少50%存储占用
### 3. 字段处理优化
- 保留部门信息,避免覆盖
- 添加提示信息,便于人工确认
- 任务描述标记,显示待确认/随机匹配状态
### 4. 优先级修改
- 将优先级选项修改为:紧急、较紧急、一般、普通
- 更新AI提示词和验证逻辑
- 更新飞书服务验证逻辑
### 5. BUG修复
- 修复None值检查避免类型错误
- 提高代码健壮性
## 扩展开发
### 添加新功能
1.`services/`目录添加新服务
2.`utils/`目录添加新工具
3.`templates/`目录添加新页面
4.`web_app.py`添加新API接口
### 修改现有功能
1. 修改对应的服务类或工具函数
2. 更新配置文件(如果需要)
3. 更新Web界面如果需要
4. 测试修改后的功能
### 调试技巧
1. 查看`app.log`日志文件
2. 使用浏览器开发者工具调试前端
3. 使用Flask调试模式`debug=True`
4. 查看飞书API响应和错误信息
## 注意事项
1. **配置安全**:不要将配置文件提交到版本控制
2. **API密钥**妥善保管API密钥避免泄露
3. **文件权限**:确保程序有读写相关目录的权限
4. **网络连接**确保网络连接稳定特别是调用AI和飞书API时
5. **资源限制**:注意内存和存储空间使用,特别是处理大图片时
## 总结
Screen2Feishu项目提供了完整的任务管理解决方案包括命令行模式和Web应用模式。通过AI技术自动分析图片和任务描述结合飞书API实现自动化任务管理大大提高了工作效率。