更新数据截止日2026.01.08

This commit is contained in:
lychang
2026-01-08 01:06:27 +08:00
commit ce02565b09
37 changed files with 12587 additions and 0 deletions

49
api/__init__.py Normal file
View File

@@ -0,0 +1,49 @@
from flask import Flask, request, render_template, Blueprint
from .v1 import v1
from .v2 import v2
from .v3 import v3
app = Flask(__name__)
app.register_blueprint(v1, url_prefix='/v1')
app.register_blueprint(v2, url_prefix='/v2')
app.register_blueprint(v3, url_prefix='/v3')
app.template_folder = '../templates'
app.static_folder = '../static'
# 页面路由
@app.route('/')
@app.route('/v1/')
@app.route('/v2/')
def index():
"""主页面 - 聊天界面"""
return render_template('index.html')
@app.route('/qa')
@app.route('/v1/qa')
@app.route('/v2/qa')
def qa_page():
"""问答管理页面"""
return render_template('qa.html')
@app.route('/weekly_report')
@app.route('/v1/weekly_report')
@app.route('/v2/weekly_report')
def weekly_report_page():
"""周报页面"""
return render_template('weekly_report.html')
@app.route('/daily_report')
@app.route('/v1/daily_report')
@app.route('/v2/daily_report')
def daily_report_page():
"""日报页面"""
return render_template('daily_report.html')
@app.route('/markdown')
def markdown_page():
"""markdown页面"""
return render_template('markdown.html')

40
api/db.py Normal file
View File

@@ -0,0 +1,40 @@
from . import app, api_response
import time
from lib import chat_session, db_manager
@app.route('/api/db/datetime')
def db_status():
return api_response(success=True, data={'message': chat_session.status()})
@app.route('/api/db/update')
def db_update():
current_time = time.localtime(time.time() - 24 * 60 * 60)
day = current_time.tm_mday
month = current_time.tm_mon
year = current_time.tm_year
if chat_session.status() == f"{year}-{month:02d}-{day:02d}-23":
return api_response(success=False, error="数据库已更新", status_code=201)
for hour in range(24):
chat_session.process_chat_session(year, month, day, hour)
start = f"{year}-{month:02d}-{day:02d} 00:00:00"
end = f"{year}-{month:02d}-{day:02d} 23:59:59"
chat_session.update_qa_data(start, end)
db_manager.upload_db()
return api_response(success=True, data={'message': '数据库更新成功'})
@app.route('/api/db/upload')
def db_upload():
db_manager.upload_db()
return api_response(success=True, data={'message': '数据库上传成功'})
@app.route('/api/db/download')
def db_download():
db_manager.download_db()
return api_response(success=True, data={'message': '数据库下载成功'})

645
api/lib.py Normal file
View File

@@ -0,0 +1,645 @@
import json
from chat_session import ChatSessionManager
from file import ObjectFileManager
from tools import from_string, from_datetime, format_timestamp, DatabaseManager
from flask import jsonify
class MessageRender:
"""消息处理类"""
@staticmethod
def format_timestamp(timestamp_str):
"""格式化时间戳"""
if not timestamp_str:
return '未知时间'
try:
timestamp = int(timestamp_str[:-3])
return format_timestamp(timestamp)
except ValueError:
return '未知时间'
@staticmethod
def process_message_content(message_data):
"""处理消息内容"""
content = message_data['content']
msg_type = message_data['type']
if msg_type == 'text':
message_data['display_content'] = content
elif msg_type in ['image', 'file', 'video', 'voice']:
message_data['display_content'] = f'[{msg_type}] {content}'
elif msg_type == 'mixed':
try:
mixed_content = json.loads(content)
display_parts = []
for item in mixed_content:
msg_type, item_content = item
if msg_type == 'text':
display_parts.append(item_content)
else:
display_parts.append(f'[{msg_type}]')
message_data['display_content'] = ' '.join(display_parts)
except ValueError:
message_data['display_content'] = '[混合消息]'
elif msg_type == 'chatrecord':
try:
chatrecord_data = json.loads(content)
if 'chatrecord' in chatrecord_data and 'title' in chatrecord_data['chatrecord']:
message_data['display_content'] = f'[聊天记录] {chatrecord_data["chatrecord"]["title"]}'
message_data['chatrecord_data'] = chatrecord_data
else:
message_data['display_content'] = '[聊天记录]'
except ValueError:
message_data['display_content'] = '[聊天记录]'
else:
message_data['display_content'] = f'[{msg_type}] {content}'
return message_data
class ReportService:
"""报告服务类"""
def __init__(self, db: DatabaseManager):
self.db = db
self.execute_query = self.db.execute_query
def get_message_count_by_time_range(self, start_timestamp, end_timestamp):
"""获取指定时间范围内的消息数量"""
result = self.execute_query('''
SELECT COUNT(*) as count
FROM message
WHERE time >= ?
AND time <= ?
''', (f"{start_timestamp}000", f"{end_timestamp}999"), fetch_all=False)
return result["count"]
def get_active_users_by_time_range(self, start_timestamp, end_timestamp):
"""获取指定时间范围内的活跃用户数量"""
result = self.execute_query('''
SELECT COUNT(DISTINCT sender) as count
FROM message
WHERE time >= ?
AND time <= ?
''', (f"{start_timestamp}000", f"{end_timestamp}999"), fetch_all=False)
return result["count"]
def get_active_sub_users_by_time_range(self, start_timestamp, end_timestamp):
"""获取指定时间范围内的活跃用户数量"""
result = self.execute_query('''
SELECT COUNT(DISTINCT sender) as count
FROM message
WHERE time >= ?
AND time <= ?
''', (f"{start_timestamp}000", f"{end_timestamp}999"), fetch_all=False)
return result["count"]
def get_qa_count_by_time_range(self, start_timestamp, end_timestamp):
"""获取指定时间范围内的QA记录数量"""
result = self.execute_query('''
SELECT COUNT(*) as count
FROM qa
WHERE start_time >= ?
AND end_time <= ?
''', (f"{start_timestamp}000", f"{end_timestamp}999"), fetch_all=False)
return result["count"]
def get_non_answer_qa_count_by_time_range(self, start_timestamp, end_timestamp):
"""获取指定时间范围内的QA记录数量"""
result = self.execute_query('''
SELECT COUNT(*) as count
FROM qa
WHERE start_time >= ?
AND end_time <= ?
AND answer = '[]'
''', (f"{start_timestamp}000", f"{end_timestamp}999"), fetch_all=False)
return result["count"]
def get_qa_count_by_time_range_sub(self, start_timestamp, end_timestamp):
"""获取指定时间范围内的QA记录数量"""
result = self.execute_query('''
SELECT COUNT(*) as count
FROM qa as q
inner join subscribe as s on q.groupId = s.id
WHERE start_time >= ?
AND end_time <= ?
''', (f"{start_timestamp}000", f"{end_timestamp}999"), fetch_all=False)
return result["count"]
def get_non_answer_qa_count_by_time_range_sub(self, start_timestamp, end_timestamp):
"""获取指定时间范围内的QA记录数量"""
result = self.execute_query('''
SELECT COUNT(*) as count
FROM qa as q
inner join subscribe as s on q.groupId = s.id
WHERE start_time >= ?
AND end_time <= ?
AND answer = '[]'
''', (f"{start_timestamp}000", f"{end_timestamp}999"), fetch_all=False)
return result["count"]
def get_group_activity_data(self, start_timestamp, end_timestamp):
"""获取群组活跃度数据"""
return self.execute_query('''
SELECT g.id, g.name, COUNT(m.id) as message_count
FROM "group" g
LEFT JOIN message m ON g.id = m.receiver AND m.time >= ? AND m.time <= ?
GROUP BY g.id, g.name
ORDER BY message_count DESC
''', (f"{start_timestamp}000", f"{end_timestamp}999"))
def get_subscribe_activity_data(self, start_timestamp, end_timestamp):
"""获取订阅活跃度数据"""
return self.execute_query('''
SELECT s.id, s.name, COUNT(m.id) as message_count
FROM subscribe s
LEFT JOIN message m ON s.id = m.receiver AND m.time >= ? AND m.time <= ?
GROUP BY s.id, s.name
ORDER BY message_count DESC
''', (f"{start_timestamp}000", f"{end_timestamp}999"))
def get_recent_messages_by_time_range(self, start_timestamp, end_timestamp, limit=100):
"""获取指定时间范围内的最近消息"""
return self.execute_query('''
SELECT content
FROM message
WHERE time >= ?
AND time <= ?
AND type NOT IN ('disagree', 'agree', 'weapp', 'link')
ORDER BY time
LIMIT ?
''', (f"{start_timestamp}000", f"{end_timestamp}999", limit))
def get_hourly_activity_data(self, start_timestamp, end_timestamp):
"""获取按小时统计的消息量"""
return self.execute_query('''
SELECT strftime('%H',
datetime(substr(time, 0, 11), 'unixepoch', '+8 hours')) as hour,
COUNT(*) as count
FROM message
WHERE time >= ?
AND time <= ?
GROUP BY hour
ORDER BY hour
''', (f"{start_timestamp}000", f"{end_timestamp}999"))
def get_group_count(self):
"""获取群组总数"""
result = self.execute_query('SELECT COUNT(*) as count FROM "group"', fetch_all=False)
return result["count"]
def get_member_count(self):
"""获取成员总数"""
result = self.execute_query('SELECT COUNT(*) as count FROM user', fetch_all=False)
return result["count"]
def get_sub_member_count(self):
"""获取成员总数"""
result = self.execute_query(
'SELECT COUNT(distinct m.userId) as count FROM member as m inner join subscribe as s on m.groupId = s.id ',
fetch_all=False)
return result["count"]
def get_subscribe_count(self):
"""获取订阅总数"""
result = self.execute_query('SELECT COUNT(*) as count FROM subscribe', fetch_all=False)
return result["count"]
def get_qa_count(self):
"""获取指定时间范围内的QA记录总数"""
result = self.execute_query('''
SELECT COUNT(*) as count
FROM qa
''', fetch_all=False)
return result["count"]
def get_weekly_messages(self, start_timestamp, end_timestamp, limit=200):
"""获取本周消息用于分类分析"""
return self.execute_query('''
SELECT content
FROM message
WHERE time >= ?
AND time <= ?
AND type NOT IN ('disagree', 'agree', 'weapp', 'link')
ORDER BY time
LIMIT ?
''', (f"{start_timestamp}000", f"{end_timestamp}999", limit))
# 通用数据查询方法 (整合剩余数据库查询)
def get_messages_by_ids(self, message_ids):
"""根据消息ID列表获取消息内容"""
if not message_ids:
return []
placeholders = ','.join(['?'] * len(message_ids))
query = f"""
SELECT m.id, m.content, m.type, m.sender, m.receiver,u.name as sender_name,
datetime(substr(time,0,11), 'unixepoch','+8 hours') as formatted_time
FROM message m
left join user u on m.sender = u.id
WHERE m.id IN ({placeholders})
AND type NOT IN ('disagree', 'agree', 'weapp', 'link')
ORDER BY formatted_time
"""
return self.execute_query(query, message_ids)
def get_messages(self, limit=100, offset=0, group_id=None):
"""获取消息列表"""
query = '''
SELECT m.*,
u.name as sender_name,
g.name as group_name,
datetime(substr(time, 0, 11), 'unixepoch', '+8 hours') as formatted_time
FROM message m
LEFT JOIN user u ON m.sender = u.id
LEFT JOIN "group" g ON m.receiver = g.id
WHERE m.type NOT IN ('disagree', 'agree', 'weapp', 'link')
AND m.toType = 1
'''
params = []
if group_id:
query += ' AND m.receiver = ?'
params.append(group_id)
query += ' ORDER BY formatted_time DESC LIMIT ? OFFSET ?'
params.extend([limit, offset])
return self.execute_query(query, params)
def get_all_users(self):
"""获取所有用户"""
return self.execute_query('SELECT * FROM user ORDER BY name')
def get_all_groups(self):
"""获取所有群组"""
return self.execute_query('SELECT * FROM "group" ORDER BY id')
def get_all_subscribes(self):
"""获取所有订阅"""
return self.execute_query('SELECT * FROM subscribe ORDER BY id')
def get_subscribe_messages(self, subscribe_id, limit=100, offset=0):
"""获取指定订阅的消息"""
if not subscribe_id:
query = '''
SELECT m.*,
u.name as sender_name,
datetime(substr(m.time, 0, 11), 'unixepoch', '+8 hours') as formatted_time
FROM message m
LEFT JOIN user u ON m.sender = u.id
WHERE m.toType = 1
AND m.type NOT IN ('disagree', 'agree', 'weapp', 'link')
ORDER BY formatted_time DESC
LIMIT ? OFFSET ? \
'''
return self.execute_query(query, (limit, offset))
else:
query = '''
SELECT m.*,
u.name as sender_name,
datetime(substr(m.time, 0, 11), 'unixepoch', '+8 hours') as formatted_time
FROM message m
LEFT JOIN user u ON m.sender = u.id
WHERE m.receiver = ?
AND m.type NOT IN ('disagree', 'agree', 'weapp', 'link')
ORDER BY formatted_time DESC
LIMIT ? OFFSET ? \
'''
return self.execute_query(query, (subscribe_id, limit, offset))
def get_subscribe_message_count(self, subscribe_id):
"""获取指定订阅的消息统计"""
subscribe_messages_total = self.execute_query('''
SELECT COUNT(*) as count
FROM message
WHERE receiver = ?
AND type NOT IN ('disagree', 'agree', 'weapp', 'link')
''', (subscribe_id,), fetch_all=False)['count']
lastest_time = self.execute_query('''
SELECT datetime(substr(time, 0, 11), 'unixepoch', '+8 hours') as formatted_time
FROM message
WHERE receiver = ?
AND type NOT IN ('disagree', 'agree', 'weapp', 'link')
ORDER BY formatted_time DESC
LIMIT 1
''', (subscribe_id,), fetch_all=False)
lastest_time = lastest_time['formatted_time'] if lastest_time else ""
return {'total': subscribe_messages_total, 'lastest_time': lastest_time}
def get_group_messages(self, group_id, limit=100, offset=0):
"""获取指定群组的消息"""
if not group_id:
query = '''
SELECT m.*,
u.name as sender_name,
datetime(substr(m.time, 0, 11), 'unixepoch', '+8 hours') as formatted_time
FROM message m
LEFT JOIN user u ON m.sender = u.id
WHERE m.toType = 1
AND m.type NOT IN ('disagree', 'agree', 'weapp', 'link')
ORDER BY formatted_time DESC
LIMIT ? OFFSET ? \
'''
return self.execute_query(query, (limit, offset))
else:
query = '''
SELECT m.*,
u.name as sender_name,
datetime(substr(m.time, 0, 11), 'unixepoch', '+8 hours') as formatted_time
FROM message m
LEFT JOIN user u ON m.sender = u.id
WHERE m.receiver = ?
AND m.type NOT IN ('disagree', 'agree', 'weapp', 'link')
ORDER BY formatted_time DESC
LIMIT ? OFFSET ? \
'''
return self.execute_query(query, (group_id, limit, offset))
def get_group_message_count(self, group_id):
"""获取指定群组的消息"""
group_messages_total = self.execute_query('''
SELECT COUNT(*) as count
FROM message
WHERE receiver = ?
AND type NOT IN ('disagree', 'agree', 'weapp', 'link')
''', (group_id,), fetch_all=False)['count']
lastest_time = self.execute_query('''
SELECT datetime(substr(time, 0, 11), 'unixepoch', '+8 hours') as formatted_time
FROM message
WHERE receiver = ?
AND type NOT IN ('disagree', 'agree', 'weapp', 'link')
ORDER BY formatted_time DESC
LIMIT 1
''', (group_id,), fetch_all=False)
lastest_time = lastest_time['formatted_time'] if lastest_time else ""
return {'total': group_messages_total, 'lastest_time': lastest_time}
def get_statistics(self):
"""获取统计数据"""
user_count = self.execute_query('SELECT COUNT(*) as count FROM user', fetch_all=False)['count']
group_count = self.execute_query('SELECT COUNT(*) as count FROM "group"', fetch_all=False)['count']
message_count = self.execute_query('SELECT COUNT(*) as count FROM message', fetch_all=False)['count']
file_count = self.execute_query('SELECT COUNT(*) as count FROM file', fetch_all=False)['count']
latest_time_result = self.execute_query('SELECT MAX(time) as latest_time FROM message', fetch_all=False)
latest_time = latest_time_result['latest_time'] if latest_time_result else None
return {
'user_count': user_count,
'group_count': group_count,
'message_count': message_count,
'file_count': file_count,
'latest_time': latest_time
}
def get_v2_statistics(self):
"""获取v2版本统计数据(包含订阅信息)"""
user_count = self.execute_query(
'SELECT COUNT(*) as count FROM user as u inner join member as m on u.id = m.userId inner join subscribe as s on m.groupId = s.id',
fetch_all=False)['count']
subscribe_count = self.execute_query('SELECT COUNT(*) as count FROM subscribe', fetch_all=False)['count']
message_count = \
self.execute_query(
'SELECT COUNT(*) as count FROM message as m inner join subscribe as s on m.receiver = s.id',
fetch_all=False)['count']
file_count = self.execute_query(
'SELECT COUNT(*) as count FROM file as f inner join message as m on f.id = m.content inner join subscribe as s on m.receiver = s.id',
fetch_all=False)['count']
latest_time_result = self.execute_query('SELECT MAX(time) as latest_time FROM message', fetch_all=False)
latest_time = latest_time_result['latest_time'] if latest_time_result else None
return {
'user_count': user_count,
'group_count': subscribe_count,
'message_count': message_count,
'file_count': file_count,
'latest_time': latest_time
}
def search_messages(self, keyword, limit=100, group_id=None):
"""搜索消息"""
if group_id:
query = '''
SELECT m.*,
u.name as sender_name,
g.name as group_name,
datetime(substr(m.time, 0, 11), 'unixepoch', '+8 hours') as formatted_time
FROM message m
LEFT JOIN user u ON m.sender = u.id
LEFT JOIN "group" g ON m.receiver = g.id
WHERE m.content LIKE ?
AND m.type NOT IN ('disagree', 'agree', 'weapp', 'link')
AND m.receiver = ?
ORDER BY formatted_time
LIMIT ? \
'''
return self.execute_query(query, (f'%{keyword}%', group_id, limit))
else:
query = '''
SELECT m.*,
u.name as sender_name,
g.name as group_name,
datetime(substr(m.time, 0, 11), 'unixepoch', '+8 hours') as formatted_time
FROM message m
LEFT JOIN user u ON m.sender = u.id
LEFT JOIN "group" g ON m.receiver = g.id
WHERE m.content LIKE ?
AND m.type NOT IN ('disagree', 'agree', 'weapp', 'link')
ORDER BY formatted_time
LIMIT ? \
'''
return self.execute_query(query, (f'%{keyword}%', limit))
def search_subscribe_messages(self, keyword, limit=100, subscribe_id=None):
"""搜索订阅消息"""
if subscribe_id:
query = '''
SELECT m.*,
u.name as sender_name,
s.name as subscribe_name,
datetime(substr(m.time, 0, 11), 'unixepoch', '+8 hours') as formatted_time
FROM message m
LEFT JOIN user u ON m.sender = u.id
LEFT JOIN subscribe s ON m.receiver = s.id
WHERE m.content LIKE ?
AND m.type NOT IN ('disagree', 'agree', 'weapp', 'link')
AND m.receiver = ?
ORDER BY formatted_time
LIMIT ? \
'''
return self.execute_query(query, (f'%{keyword}%', subscribe_id, limit))
else:
query = '''
SELECT m.*,
u.name as sender_name,
s.name as subscribe_name,
datetime(substr(m.time, 0, 11), 'unixepoch', '+8 hours') as formatted_time
FROM message m
LEFT JOIN user u ON m.sender = u.id
LEFT JOIN subscribe s ON m.receiver = s.id
WHERE m.content LIKE ?
AND m.type NOT IN ('disagree', 'agree', 'weapp', 'link')
ORDER BY formatted_time
LIMIT ? \
'''
return self.execute_query(query, (f'%{keyword}%', limit))
def get_all_qa(self):
"""获取所有QA记录"""
return self.execute_query('SELECT * FROM qa ORDER BY id DESC')
def get_qa_by_id(self, qa_id):
"""根据ID获取QA记录"""
return self.execute_query('SELECT * FROM qa WHERE id = ?', (qa_id,), fetch_all=False)
def check_qa_exists(self, qa_id):
"""检查QA记录是否存在"""
result = self.execute_query('SELECT id FROM qa WHERE id = ?', (qa_id,), fetch_all=False)
return result is not None
# 报告生成方法 (原ReportGenerator功能)
@staticmethod
def get_date_range(date_str):
"""获取日期范围"""
target_date = from_string(date_str).to_datetime()
start_timestamp = int(from_string(date_str).start_of_day().to_timestamp())
end_timestamp = int(from_string(date_str).end_of_day().to_timestamp())
return target_date, start_timestamp, end_timestamp
@staticmethod
def get_previous_day_range(target_date):
"""获取前一天日期范围"""
yesterday_date = from_datetime(target_date).days_ago(1)
yesterday_start = int(yesterday_date.start_of_day().to_timestamp())
yesterday_end = int(yesterday_date.end_of_day().to_timestamp())
return yesterday_start, yesterday_end
@staticmethod
def calculate_change_rate(current, previous):
"""计算变化率"""
if previous > 0:
return (current - previous) / previous * 100
return 0
@staticmethod
def analyze_hot_topics(messages):
"""分析热点话题"""
keyword_patterns = ['季度报告', 'ESG', '股东大会', '披露', '系统', '操作', '政策']
topic_counts = {}
for msg in messages:
content = msg['content']
for keyword in keyword_patterns:
if keyword in content:
topic_counts[keyword] = topic_counts.get(keyword, 0) + 1
# 排序并取前3个热点
sorted_topics = sorted(topic_counts.items(), key=lambda x: x[1], reverse=True)
return [f"{topic} (提及{count}次)" for topic, count in sorted_topics[:3]]
@staticmethod
def analyze_category_breakdown(messages):
"""分析问题分类"""
category_counts = {
'信息披露类': 0.0,
'公司治理与法规': 0.0,
'业务操作与系统': 0.0,
'其他': 0.0
}
category_keywords = {
'信息披露类': ['披露', '报告', '公告', '季报', '年报', 'ESG'],
'公司治理与法规': ['法规', '合规', '治理', '政策', '规定', '要求'],
'业务操作与系统': ['系统', '操作', '填报', '上传', '下载', '登录']
}
for msg in messages:
content = msg['content']
categorized = False
for category, keywords in category_keywords.items():
for keyword in keywords:
if keyword in content:
category_counts[category] += 1
categorized = True
break
if categorized:
break
if not categorized:
category_counts['其他'] += 1
# 计算百分比
total_categorized = sum(category_counts.values())
if total_categorized > 0:
for category in category_counts:
category_counts[category] = round(category_counts[category] / total_categorized * 100, 1)
return category_counts
@staticmethod
def generate_group_activity_chart(group_activity_data):
"""生成分群活跃度直方图"""
if not group_activity_data:
return "暂无数据"
# 获取前10个群组的数据
top_groups = group_activity_data[:10]
max_count = max(group['message_count'] for group in top_groups) if top_groups else 1
chart = ""
for group in top_groups:
group_name = group['name'] # 限制名称长度
count = group['message_count']
# 计算柱状图长度(最大50个字符)
bar_length = int((count / max_count) * 50) if max_count > 0 else 0
bar = "█" * bar_length
chart += f"{group_name:<15} | {bar} {count}\n"
return chart
def generate_hourly_activity_chart(self, start_timestamp, end_timestamp):
"""生成活跃时段直方图"""
# 获取按小时统计的消息量
hourly_data = self.get_hourly_activity_data(start_timestamp, end_timestamp)
if not hourly_data:
return "暂无数据"
# 创建24小时的完整数据
hour_counts = {str(i).zfill(2): 0 for i in range(24)}
for data in hourly_data:
hour_counts[data['hour']] = data['count']
max_count = max(hour_counts.values()) if hour_counts else 1
chart = ""
for hour in sorted(hour_counts.keys()):
count = hour_counts[hour]
# 计算柱状图长度(最大30个字符)
bar_length = int((count / max_count) * 30) if max_count > 0 else 0
bar = "█" * bar_length
chart += f"{hour}:00 | {bar} {count}\n"
return chart
def api_response(success=True, data=None, error=None, status_code=200, content=None):
"""统一的API响应格式"""
response = {'success': success}
if error:
response['error'] = str(error)
if data:
response.update(data)
if content:
response["data"] = content
return jsonify(response), status_code
ofm = ObjectFileManager()
db_manager = DatabaseManager('db/data', online_db_uri="zysl-log/wework/chat_msg/db/data.db")
report_service = ReportService(db_manager)
message_processor = MessageRender()
chat_session = ChatSessionManager(db_manager)

677
api/v1.py Normal file
View File

@@ -0,0 +1,677 @@
from datetime import datetime, timedelta
from api.lib import report_service, message_processor, db_manager
from . import request, Blueprint
from .lib import api_response
import json
from chat_session import agent
from tools import TimeUtils, now, from_datetime
v1 = Blueprint('v1', __name__)
# API路由 - 消息相关
@v1.route('/api/messages_by_ids', methods=['POST'])
def get_messages_by_ids():
"""根据消息ID列表获取消息内容"""
try:
data = request.get_json()
message_ids = data.get('message_ids', [])
if not message_ids:
return api_response(data={'messages': []})
messages_data = report_service.get_messages_by_ids(message_ids)
formatted_messages = []
for msg in messages_data:
message = dict(msg)
message = message_processor.process_message_content(message)
formatted_messages.append(message)
return api_response(data={'messages': formatted_messages})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
@v1.route('/api/messages')
def get_messages():
"""获取消息列表API"""
try:
limit = request.args.get('limit', 100, type=int)
offset = request.args.get('offset', 0, type=int)
group_id = request.args.get('group_id', type=str)
messages_data = report_service.get_messages(limit, offset, group_id)
formatted_messages = []
for msg in messages_data:
message = dict(msg)
message = message_processor.process_message_content(message)
formatted_messages.append(message)
return api_response(data={
'messages': formatted_messages,
'total': len(formatted_messages)
})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
# API路由 - 用户和群组
@v1.route('/api/users')
def get_users():
"""获取用户列表API"""
try:
users_data = report_service.get_all_users()
users = [dict(user) for user in users_data]
return api_response(data={'users': users})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
@v1.route('/api/groups')
def get_groups():
"""获取群组列表API"""
try:
groups_data = report_service.get_all_groups()
groups = [dict(group) for group in groups_data]
return api_response(data={'groups': groups})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
@v1.route('/api/group_messages')
def get_group_messages():
"""获取指定群组的消息API"""
try:
group_id = request.args.get('group_id', type=str)
limit = request.args.get('limit', 100, type=int)
offset = request.args.get('offset', 0, type=int)
if not group_id:
return api_response(success=False, error='缺少群组ID参数', status_code=400)
messages_data = report_service.get_group_messages(group_id, limit, offset)
group_messages_info = report_service.get_group_message_count(group_id)
formatted_messages = []
for msg in messages_data:
message = dict(msg)
message = message_processor.process_message_content(message)
formatted_messages.append(message)
return api_response(data={'messages': formatted_messages, **group_messages_info})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
# API路由 - 统计信息
@v1.route('/api/statistics')
def get_statistics():
"""获取统计数据API"""
try:
statistics = report_service.get_statistics()
return api_response(data=statistics)
except Exception as e:
return api_response(success=False, error=e, status_code=500)
# API路由 - 搜索
@v1.route('/api/search_messages')
def search_messages():
"""搜索消息API"""
try:
keyword = request.args.get('keyword', '')
limit = request.args.get('limit', 100, type=int)
search_type = request.args.get('search_type', 'all') # all, question, answer
if not keyword:
return api_response(success=False, error='请输入搜索关键词', status_code=400)
group_id = request.args.get('group_id', type=str)
# 智能搜索:支持模糊匹配和精确匹配
messages_data = report_service.search_messages(keyword, limit, group_id)
# 根据搜索类型过滤消息
if search_type == 'question':
messages_data = [msg for msg in messages_data if msg.get('type') in ['text', 'mixed']]
elif search_type == 'answer':
messages_data = [msg for msg in messages_data if msg.get('type') in ['text', 'mixed']]
formatted_messages = []
for msg in messages_data:
message = dict(msg)
message = message_processor.process_message_content(message)
# 添加搜索关键词高亮
if keyword:
content = message.get('content', '')
if keyword.lower() in content.lower():
# 简单的关键词高亮(前端可以进一步处理)
highlighted_content = content.replace(keyword, f'<mark>{keyword}</mark>')
message['highlighted_content'] = highlighted_content
formatted_messages.append(message)
return api_response(data={'messages': formatted_messages, 'total': len(formatted_messages)})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
# API路由 - 搜索建议
@v1.route('/api/search_suggestions')
def search_suggestions():
"""搜索建议API"""
try:
keyword = request.args.get('keyword', '')
if not keyword:
return api_response(data={'suggestions': []})
# 获取热门搜索关键词
suggestions = []
# 1. 从消息内容中提取相关关键词
try:
messages = report_service.search_messages(keyword, 10, None)
for msg in messages:
content = msg.get('content', '')
# 简单的关键词提取逻辑
words = content.split()
for word in words:
if len(word) > 2 and keyword.lower() in word.lower():
if word not in suggestions:
suggestions.append(word)
if len(suggestions) >= 5:
break
if len(suggestions) >= 5:
break
except:
pass
# 2. 添加常见搜索关键词
common_keywords = ['信息披露', '公司治理', '业务操作', '系统填报', '政策法规', '季度报告', '股东大会']
for kw in common_keywords:
if keyword.lower() in kw.lower() and kw not in suggestions:
suggestions.append(kw)
if len(suggestions) >= 8:
break
return api_response(data={'suggestions': suggestions[:8]})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
# API路由 - QA管理
@v1.route('/api/qa')
def get_qa_list():
"""获取QA列表API"""
try:
# 获取查询参数
group_id = request.args.get('group_id', '')
keyword = request.args.get('keyword', '')
start_time = request.args.get('start_time', '')
end_time = request.args.get('end_time', '')
# 构建查询条件
query_conditions = []
query_params = []
if group_id:
query_conditions.append("groupId = ?")
query_params.append(group_id)
if start_time and end_time:
try:
# 将时间字符串转换为时间戳(毫秒级)
start_timestamp = int(TimeUtils(start_time + ' 00:00:00').to_timestamp() * 1000)
end_timestamp = int(TimeUtils(end_time + ' 23:59:59').to_timestamp() * 1000)
query_conditions.append("start_time >= ? AND end_time <= ?")
query_params.extend([start_timestamp, end_timestamp])
except ValueError:
# 时间格式错误时,忽略时间条件
pass
# 构建查询语句
base_query = "SELECT * FROM qa"
if query_conditions:
base_query += " WHERE " + " AND ".join(query_conditions)
base_query += " ORDER BY id DESC"
# 执行查询
conn = db_manager.get_connection()
cursor = conn.cursor()
cursor.execute(base_query, query_params)
qa_data = cursor.fetchall()
conn.close()
qa_list = [dict(qa) for qa in qa_data]
# 如果有关键词搜索,进行内容过滤
if keyword:
filtered_qa_list = []
for qa in qa_list:
# 检查群组ID是否匹配
if group_id != qa.get('groupId') and group_id != "":
continue
# 检查问题内容是否匹配
try:
question_ids = json.loads(qa.get('question', '[]')) + json.loads(qa.get('answer', '[]'))
if question_ids:
# 获取问题消息内容进行匹配
messages = report_service.get_messages_by_ids(question_ids)
for msg in messages:
if keyword.lower() in (msg['content'] or '').lower():
filtered_qa_list.append(qa)
break
except Exception as e:
print(e)
pass
qa_list = filtered_qa_list
return api_response(data={'qa_list': qa_list, 'total': len(qa_list)})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
@v1.route('/api/qa/<int:qa_id>')
def get_qa_detail(qa_id):
"""获取单个QA详情API"""
try:
qa_data = report_service.get_qa_by_id(qa_id)
if not qa_data:
return api_response(success=False, error='QA记录不存在', status_code=404)
qa_data = dict(qa_data)
try:
qa_data['question_list'] = json.loads(qa_data['question'])
qa_data['answer_list'] = json.loads(qa_data['answer'])
except:
qa_data['question_list'] = []
qa_data['answer_list'] = []
return api_response(data={'qa': qa_data})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
@v1.route('/api/qa', methods=['POST'])
def create_qa():
"""创建QA记录API"""
try:
data = request.get_json()
group_id = data.get('groupId', '')
question_ids = data.get('question', [])
answer_ids = data.get('answer', [])
# 处理时间字符串,转换为时间戳(毫秒级)
start_time = data.get('start_time', '')
end_time = data.get('end_time', '')
try:
if start_time:
start_time = int(TimeUtils(start_time).to_timestamp() * 1000)
if end_time:
end_time = int(TimeUtils(end_time).to_timestamp() * 1000)
except ValueError:
# 时间格式错误时,使用空字符串
start_time = ''
end_time = ''
conn = db_manager.get_connection()
cursor = conn.cursor()
index_id = cursor.execute_query(f"SELECT id FROM qa order by id desc limit 1", fetch_all=False)[0]
index_id += 1
cursor.execute('''
INSERT INTO qa ("id", "groupId", "question", "answer", "start_time", "end_time")
VALUES (?, ?, ?, ?, ?, ?)
''', (index_id, group_id, json.dumps(question_ids), json.dumps(answer_ids),
start_time, end_time))
conn.commit()
qa_id = cursor.lastrowid
conn.close()
return api_response(data={
'id': qa_id,
'message': 'QA记录创建成功'
})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
@v1.route('/api/qa/<int:qa_id>', methods=['PUT'])
def update_qa(qa_id):
"""更新QA记录API"""
try:
data = request.get_json()
group_id = data.get('groupId', '')
question_ids = data.get('question', [])
answer_ids = data.get('answer', [])
# 处理时间字符串,转换为时间戳(毫秒级)
start_time = data.get('start_time', '')
end_time = data.get('end_time', '')
try:
if start_time:
start_time = int(TimeUtils(start_time).to_timestamp() * 1000)
if end_time:
end_time = int(TimeUtils(end_time).to_timestamp() * 1000)
except ValueError:
# 时间格式错误时,保持原值不更新
pass
# 检查记录是否存在
if not report_service.check_qa_exists(qa_id):
return api_response(success=False, error='QA记录不存在', status_code=404)
conn = db_manager.get_connection()
cursor = conn.cursor()
# 如果提供了时间参数,则更新时间字段
if start_time and end_time:
cursor.execute('''
UPDATE qa
SET groupId = ?,
question = ?,
answer = ?,
start_time = ?,
end_time = ?
WHERE id = ?
''',
(group_id, json.dumps(question_ids), json.dumps(answer_ids), start_time, end_time, qa_id))
else:
cursor.execute('''
UPDATE qa
SET groupId = ?,
question = ?,
answer = ?
WHERE id = ?
''', (group_id, json.dumps(question_ids), json.dumps(answer_ids), qa_id))
conn.commit()
conn.close()
return api_response(data={'message': 'QA记录更新成功'})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
@v1.route('/api/qa/<int:qa_id>', methods=['DELETE'])
def delete_qa(qa_id):
"""删除单个QA记录API"""
try:
# 检查记录是否存在
if not report_service.check_qa_exists(qa_id):
return api_response(success=False, error='QA记录不存在', status_code=404)
conn = db_manager.get_connection()
cursor = conn.cursor()
cursor.execute('DELETE FROM qa WHERE id = ?', (qa_id,))
conn.commit()
conn.close()
return api_response(data={'message': 'QA记录删除成功'})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
@v1.route('/api/qa/batch', methods=['DELETE'])
def batch_delete_qa():
"""批量删除QA记录API"""
try:
data = request.get_json()
qa_ids = data.get('qa_ids', [])
if not qa_ids:
return api_response(success=False, error='请提供要删除的QA记录ID列表', status_code=400)
# 检查所有记录是否存在
for qa_id in qa_ids:
if not report_service.check_qa_exists(qa_id):
return api_response(success=False, error=f'QA记录 {qa_id} 不存在', status_code=404)
conn = db_manager.get_connection()
cursor = conn.cursor()
# 构建批量删除语句
placeholders = ','.join(['?'] * len(qa_ids))
query = f'DELETE FROM qa WHERE id IN ({placeholders})'
cursor.execute(query, qa_ids)
conn.commit()
conn.close()
return api_response(status_code=404)
except Exception as e:
return api_response(success=False, error=e, status_code=500)
# API路由 - 报告生成
@v1.route('/api/reports/daily')
def generate_daily_report():
"""生成日报API"""
try:
date_str = request.args.get('date', now().to_string(TimeUtils.FORMAT_DATE))
target_date, start_timestamp, end_timestamp = report_service.get_date_range(date_str)
yesterday_start, yesterday_end = report_service.get_previous_day_range(target_date)
# 使用ReportService类获取数据
today_message_count = report_service.get_message_count_by_time_range(start_timestamp, end_timestamp)
yesterday_message_count = report_service.get_message_count_by_time_range(yesterday_start, yesterday_end)
message_change_rate = report_service.calculate_change_rate(
today_message_count, yesterday_message_count
)
# 获取活跃用户数
today_active_users = report_service.get_active_users_by_time_range(start_timestamp, end_timestamp)
yesterday_active_users = report_service.get_active_users_by_time_range(yesterday_start, yesterday_end)
user_change_rate = report_service.calculate_change_rate(
today_active_users, yesterday_active_users
)
# 获取群组和成员数量
group_count = report_service.get_group_count()
member_count = report_service.get_member_count()
# 获取QA记录数量
today_qa_count = report_service.get_qa_count_by_time_range(start_timestamp, end_timestamp)
yesterday_qa_count = report_service.get_qa_count_by_time_range(yesterday_start, yesterday_end)
non_qa_count = report_service.get_non_answer_qa_count_by_time_range(start_timestamp, end_timestamp)
qa_change_rate = report_service.calculate_change_rate(
today_qa_count, yesterday_qa_count
)
# 获取群组活跃度
group_activity_data = report_service.get_group_activity_data(start_timestamp, end_timestamp)
# 获取热点话题
recent_messages = report_service.get_recent_messages_by_time_range(start_timestamp, end_timestamp, 100)
hot_topics = report_service.analyze_hot_topics(recent_messages)
# 生成直方图
group_activity_chart = report_service.generate_group_activity_chart(group_activity_data)
hourly_activity_chart = report_service.generate_hourly_activity_chart(start_timestamp, end_timestamp)
# 生成日报
report_date = from_datetime(target_date).format_custom('%Y年%m月%d日 (%A)')
daily_report = f"""# **致远速联高端社群运营日报**
**报告周期**:{report_date}
**覆盖范围**:{group_count}个群,总计成员 {member_count} 人
## **1. 核心数据概览**
| 指标 | 今日数据 | 昨日数据 | 变化 |
| :--- | :--- | :--- | :--- |
| 总消息量 | {today_message_count} 条 | {yesterday_message_count} 条 | {'↑' if message_change_rate >= 0 else '↓'}{abs(message_change_rate):.1f}% |
| 活跃人数 | {today_active_users} 人 | {yesterday_active_users} 人 | {'↑' if user_change_rate >= 0 else '↓'}{abs(user_change_rate):.1f}% |
| 问题新增记录 | {today_qa_count} 条 | {yesterday_qa_count} 条 | {'↑' if qa_change_rate >= 0 else '↓'}{abs(qa_change_rate):.1f}% |
## **2. 分群活跃度对比**
```
{group_activity_chart}
```
## **3. 今日热点与时段**
### **热点问题TOP 3**:
"""
for i, topic in enumerate(hot_topics, 1):
daily_report += f" {i}. {topic}\n"
daily_report += f"""### **活跃时段**:消息高峰分布
```
{hourly_activity_chart}
```
## **4. 待办事项**
{"有" + str(non_qa_count) + "条未被回答的问题,关注活跃群组的后续讨论,确保所有疑问被解答。" if non_qa_count > 0 else "无未被回答的问题,继续保持活跃群组的讨论。"}
明日重点关注:根据今日热点预测明日可能的问题。
"""
return api_response(data={
'report': daily_report,
'date': date_str,
'statistics': {
'today_message_count': today_message_count,
'yesterday_message_count': yesterday_message_count,
'message_change_rate': message_change_rate,
'today_active_users': today_active_users,
'yesterday_active_users': yesterday_active_users,
'user_change_rate': user_change_rate,
'today_qa_count': today_qa_count,
'yesterday_qa_count': yesterday_qa_count,
'qa_change_rate': qa_change_rate,
'group_count': group_count,
'member_count': member_count
}
})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
@v1.route('/api/reports/weekly')
def generate_weekly_report():
"""生成周报API"""
try:
week_str = request.args.get('week', now().format_custom('%Y-%W'))
# 解析week_str参数,格式为YYYY-WW
year, week_num = map(int, week_str.split('-'))
# 根据周数计算时间范围
# 获取指定年份的第一天
first_day_of_year = datetime(year, 1, 1)
# 计算第一周的周一(ISO周从周一开始)
first_monday = first_day_of_year + timedelta(days=(7 - first_day_of_year.weekday()) % 7)
if first_day_of_year.weekday() <= 3: # 如果1月1日是周一到周四,则属于第一周
first_monday = first_day_of_year - timedelta(days=first_day_of_year.weekday())
# 计算指定周的周一
week_monday = first_monday + timedelta(weeks=week_num - 1)
# 计算指定周的周日
week_sunday = week_monday + timedelta(days=6)
start_date = week_monday.replace(hour=0, minute=0, second=0, microsecond=0)
end_date = week_sunday.replace(hour=23, minute=59, second=59, microsecond=999999)
start_timestamp = int(from_datetime(start_date).to_timestamp())
end_timestamp = int(from_datetime(end_date).to_timestamp())
# 获取上周同期数据
last_week_start = start_date - timedelta(weeks=1)
last_week_end = end_date - timedelta(weeks=1)
last_week_start_ts = int(from_datetime(last_week_start).to_timestamp())
last_week_end_ts = int(from_datetime(last_week_end).to_timestamp())
# 使用ReportService类获取数据
week_message_count = report_service.get_message_count_by_time_range(start_timestamp, end_timestamp)
last_week_message_count = report_service.get_message_count_by_time_range(last_week_start_ts, last_week_end_ts)
message_change_rate = report_service.calculate_change_rate(
week_message_count, last_week_message_count
)
# 获取活跃用户数
week_active_users = report_service.get_active_users_by_time_range(start_timestamp, end_timestamp)
# 获取群组和成员数量
group_count = report_service.get_group_count()
member_count = report_service.get_member_count()
# 获取QA记录数量
qa_count = report_service.get_qa_count_by_time_range(start_timestamp, end_timestamp)
non_answer_qa_count = report_service.get_non_answer_qa_count_by_time_range(start_timestamp, end_timestamp)
# 获取本周消息用于分类分析
week_messages = report_service.get_weekly_messages(start_timestamp, end_timestamp, 200)
category_breakdown = report_service.analyze_category_breakdown(week_messages)
# 生成周报
week_period = f"{from_datetime(start_date).format_custom('%Y年%m月%d日')} - {from_datetime(end_date).format_custom('%Y年%m月%d日')} (第{week_str.split('-')[1]}周)"
weekly_report = f"""# **致远速联高端社群运营周报**
**报告周期**:{week_period}
## **1. 本周摘要**
- 本周社群总消息量 **{week_message_count}条**,{'环比上升' if message_change_rate >= 0 else '环比下降'} **{abs(message_change_rate):.1f}%**。
- 活跃用户数达到 **{week_active_users}人**,社群参与度良好。
- 新增问题解答记录 **{qa_count}条**,知识沉淀效果显著。
- 其中,有 **{non_answer_qa_count}条** 未被回答,值得注意。
## **2. 深度洞察:上市公司提问分类**
"""
# 添加问题分类信息
for category, percentage in category_breakdown.items():
weekly_report += f"- {category}:{percentage}%\n"
weekly_report += f"""\n**洞察**:问题集中在信披和法规,说明成员对合规性高度重视。
## **3. 内容价值与知识沉淀**
- **本周高价值问答**:系统自动识别并记录了{qa_count}条有价值的问答记录。
- **建议**:将高频问答整理至知识库,便于后续参考。
"""
result = agent.chat_deepseek(weekly_report,
"根据下面的内容给出总结和建议,格式如下:\n## **4. 总结与建议**\n**总结**:\n**建议**:\n")
return api_response(data={
'report': weekly_report + "\n" + result,
'week': week_str,
'statistics': {
'week_message_count': week_message_count,
'last_week_message_count': last_week_message_count,
'message_change_rate': message_change_rate,
'week_active_users': week_active_users,
'group_count': group_count,
'member_count': member_count,
'qa_count': qa_count,
'category_breakdown': category_breakdown
}
})
except Exception as e:
return api_response(success=False, error=e, status_code=500)

641
api/v2.py Normal file
View File

@@ -0,0 +1,641 @@
from datetime import datetime, timedelta
from api.lib import report_service, message_processor, db_manager
from . import request, Blueprint
from .lib import api_response
import json
from chat_session import agent
from tools import TimeUtils, now, from_datetime
v2 = Blueprint('v2', __name__)
# API路由 - 消息相关
@v2.route('/api/messages_by_ids', methods=['POST'])
def get_messages_by_ids():
"""根据消息ID列表获取消息内容"""
try:
data = request.get_json()
message_ids = data.get('message_ids', [])
if not message_ids:
return api_response(data={'messages': []})
messages_data = report_service.get_messages_by_ids(message_ids)
formatted_messages = []
for msg in messages_data:
message = dict(msg)
message = message_processor.process_message_content(message)
formatted_messages.append(message)
return api_response(data={'messages': formatted_messages})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
# v2 API路由 - 使用与v1相同命名但使用subscribe表
@v2.route('/api/groups')
def get_groups():
"""获取订阅列表API (v2版本,使用subscribe表)"""
try:
subscribes_data = report_service.get_all_subscribes()
groups = [dict(subscribe) for subscribe in subscribes_data]
return api_response(data={'groups': groups})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
@v2.route('/api/group_messages')
def get_group_messages():
"""获取指定订阅的消息API (v2版本,使用subscribe表)"""
try:
group_id = request.args.get('group_id', type=str)
limit = request.args.get('limit', 100, type=int)
offset = request.args.get('offset', 0, type=int)
if not group_id:
return api_response(success=False, error='缺少群组ID参数', status_code=400)
messages_data = report_service.get_subscribe_messages(group_id, limit, offset)
group_messages_info = report_service.get_subscribe_message_count(group_id)
formatted_messages = []
for msg in messages_data:
message = dict(msg)
message = message_processor.process_message_content(message)
formatted_messages.append(message)
return api_response(data={'messages': formatted_messages, **group_messages_info})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
# v2 API路由 - 统计信息
@v2.route('/api/statistics')
def get_statistics():
"""获取统计数据API (v2版本,使用subscribe表)"""
try:
statistics = report_service.get_v2_statistics()
return api_response(data=statistics)
except Exception as e:
return api_response(success=False, error=e, status_code=500)
# v2 API路由 - 搜索
@v2.route('/api/search_messages')
def search_messages():
"""搜索消息API (v2版本,使用subscribe表)"""
try:
keyword = request.args.get('keyword', '')
limit = request.args.get('limit', 100, type=int)
search_type = request.args.get('search_type', 'all') # all, question, answer
if not keyword:
return api_response(success=False, error='请输入搜索关键词', status_code=400)
group_id = request.args.get('group_id', type=str)
# 智能搜索:支持模糊匹配和精确匹配
messages_data = report_service.search_subscribe_messages(keyword, limit, group_id)
# 根据搜索类型过滤消息
if search_type == 'question':
messages_data = [msg for msg in messages_data if msg.get('type') in ['text', 'mixed']]
elif search_type == 'answer':
messages_data = [msg for msg in messages_data if msg.get('type') in ['text', 'mixed']]
formatted_messages = []
for msg in messages_data:
message = dict(msg)
message = message_processor.process_message_content(message)
# 添加搜索关键词高亮
if keyword:
content = message.get('content', '')
if keyword.lower() in content.lower():
# 简单的关键词高亮(前端可以进一步处理)
highlighted_content = content.replace(keyword, f'<mark>{keyword}</mark>')
message['highlighted_content'] = highlighted_content
formatted_messages.append(message)
return api_response(data={'messages': formatted_messages, 'total': len(formatted_messages)})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
# API路由 - 搜索建议
@v2.route('/api/search_suggestions')
def search_suggestions():
"""搜索建议API"""
try:
keyword = request.args.get('keyword', '')
if not keyword:
return api_response(data={'suggestions': []})
# 获取热门搜索关键词
suggestions = []
# 1. 从消息内容中提取相关关键词
try:
messages = report_service.search_messages(keyword, 10, None)
for msg in messages:
content = msg.get('content', '')
# 简单的关键词提取逻辑
words = content.split()
for word in words:
if len(word) > 2 and keyword.lower() in word.lower():
if word not in suggestions:
suggestions.append(word)
if len(suggestions) >= 5:
break
if len(suggestions) >= 5:
break
except:
pass
# 2. 添加常见搜索关键词
common_keywords = ['信息披露', '公司治理', '业务操作', '系统填报', '政策法规', '季度报告', '股东大会']
for kw in common_keywords:
if keyword.lower() in kw.lower() and kw not in suggestions:
suggestions.append(kw)
if len(suggestions) >= 8:
break
return api_response(data={'suggestions': suggestions[:8]})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
# v2 API路由 - QA管理 (使用subscribe表)
@v2.route('/api/qa')
def get_qa_list():
"""获取QA列表API (v2版本,使用subscribe表)"""
try:
# 获取查询参数
group_id = request.args.get('group_id', '')
keyword = request.args.get('keyword', '')
start_time = request.args.get('start_time', '')
end_time = request.args.get('end_time', '')
# 构建查询条件
query_conditions = []
query_params = []
if group_id:
query_conditions.append("a.groupId = ?")
query_params.append(group_id)
if start_time and end_time:
try:
# 将时间字符串转换为时间戳(毫秒级)
start_timestamp = int(TimeUtils(start_time + ' 00:00:00').to_timestamp() * 1000)
end_timestamp = int(TimeUtils(end_time + ' 23:59:59').to_timestamp() * 1000)
query_conditions.append("a.start_time >= ? AND a.end_time <= ?")
query_params.extend([start_timestamp, end_timestamp])
except ValueError:
# 时间格式错误时,忽略时间条件
pass
# 构建查询语句
base_query = "SELECT a.* FROM subscribe as s inner join qa as a on a.groupId = s.id"
if query_conditions:
base_query += " WHERE " + " AND ".join(query_conditions)
base_query += " ORDER BY a.id DESC"
# 执行查询
conn = db_manager.get_connection()
cursor = conn.cursor()
cursor.execute(base_query, query_params)
qa_data = cursor.fetchall()
conn.close()
qa_list = [dict(qa) for qa in qa_data]
# 如果有关键词搜索,进行内容过滤
if keyword:
filtered_qa_list = []
for qa in qa_list:
# 检查群组ID是否匹配
if group_id != qa.get('groupId') and group_id != "":
continue
# 检查问题内容是否匹配
try:
question_ids = json.loads(qa.get('question', '[]')) + json.loads(qa.get('answer', '[]'))
if question_ids:
# 获取问题消息内容进行匹配
messages = report_service.get_messages_by_ids(question_ids)
for msg in messages:
if keyword.lower() in (msg['content'] or '').lower():
filtered_qa_list.append(qa)
break
except Exception as e:
print(e)
pass
qa_list = filtered_qa_list
return api_response(data={'qa_list': qa_list, 'total': len(qa_list)})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
@v2.route('/api/qa/<int:qa_id>')
def get_qa_detail(qa_id):
"""获取单个QA详情API (v2版本,使用subscribe表)"""
try:
qa_data = report_service.get_qa_by_id(qa_id)
if not qa_data:
return api_response(success=False, error='QA记录不存在', status_code=404)
qa_data = dict(qa_data)
try:
qa_data['question_list'] = json.loads(qa_data['question'])
qa_data['answer_list'] = json.loads(qa_data['answer'])
except:
qa_data['question_list'] = []
qa_data['answer_list'] = []
return api_response(data={'qa': qa_data})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
@v2.route('/api/qa', methods=['POST'])
def create_qa():
"""创建QA记录API (v2版本,使用subscribe表)"""
try:
data = request.get_json()
group_id = data.get('groupId', '')
question_ids = data.get('question', [])
answer_ids = data.get('answer', [])
# 处理时间字符串,转换为时间戳(毫秒级)
start_time = data.get('start_time', '')
end_time = data.get('end_time', '')
try:
if start_time:
start_time = int(TimeUtils(start_time).to_timestamp() * 1000)
if end_time:
end_time = int(TimeUtils(end_time).to_timestamp() * 1000)
except ValueError:
# 时间格式错误时,使用空字符串
start_time = ''
end_time = ''
conn = db_manager.get_connection()
cursor = conn.cursor()
index_id = cursor.execute_query(f"SELECT id FROM qa order by id desc limit 1", fetch_all=False)[0]
index_id += 1
cursor.execute('''
INSERT INTO qa ("id", "groupId", "question", "answer", "start_time", "end_time")
VALUES (?, ?, ?, ?, ?, ?)
''', (index_id, group_id, json.dumps(question_ids), json.dumps(answer_ids),
start_time, end_time))
conn.commit()
qa_id = cursor.lastrowid
conn.close()
return api_response(data={
'id': qa_id,
'message': 'QA记录创建成功'
})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
@v2.route('/api/qa/<int:qa_id>', methods=['PUT'])
def update_qa(qa_id):
"""更新QA记录API (v2版本,使用subscribe表)"""
try:
data = request.get_json()
group_id = data.get('groupId', '')
question_ids = data.get('question', [])
answer_ids = data.get('answer', [])
# 处理时间字符串,转换为时间戳(毫秒级)
start_time = data.get('start_time', '')
end_time = data.get('end_time', '')
try:
if start_time:
start_time = int(TimeUtils(start_time).to_timestamp() * 1000)
if end_time:
end_time = int(TimeUtils(end_time).to_timestamp() * 1000)
except ValueError:
# 时间格式错误时,保持原值不更新
pass
# 检查记录是否存在
if not report_service.check_qa_exists(qa_id):
return api_response(success=False, error='QA记录不存在', status_code=404)
conn = db_manager.get_connection()
cursor = conn.cursor()
# 如果提供了时间参数,则更新时间字段
if start_time and end_time:
cursor.execute('''
UPDATE qa
SET groupId = ?,
question = ?,
answer = ?,
start_time = ?,
end_time = ?
WHERE id = ?
''',
(group_id, json.dumps(question_ids), json.dumps(answer_ids), start_time, end_time, qa_id))
else:
cursor.execute('''
UPDATE qa
SET groupId = ?,
question = ?,
answer = ?
WHERE id = ?
''', (group_id, json.dumps(question_ids), json.dumps(answer_ids), qa_id))
conn.commit()
conn.close()
return api_response(data={'message': 'QA记录更新成功'})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
@v2.route('/api/qa/<int:qa_id>', methods=['DELETE'])
def delete_qa(qa_id):
"""删除单个QA记录API (v2版本,使用subscribe表)"""
try:
# 检查记录是否存在
if not report_service.check_qa_exists(qa_id):
return api_response(success=False, error='QA记录不存在', status_code=404)
conn = db_manager.get_connection()
cursor = conn.cursor()
cursor.execute('DELETE FROM qa WHERE id = ?', (qa_id,))
conn.commit()
conn.close()
return api_response(data={'message': 'QA记录删除成功'})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
@v2.route('/api/qa/batch', methods=['DELETE'])
def batch_delete_qa():
"""批量删除QA记录API (v2版本,使用subscribe表)"""
try:
data = request.get_json()
qa_ids = data.get('qa_ids', [])
if not qa_ids:
return api_response(success=False, error='请提供要删除的QA记录ID列表', status_code=400)
# 检查所有记录是否存在
for qa_id in qa_ids:
if not report_service.check_qa_exists(qa_id):
return api_response(success=False, error=f'QA记录 {qa_id} 不存在', status_code=404)
conn = db_manager.get_connection()
cursor = conn.cursor()
# 构建批量删除语句
placeholders = ','.join(['?'] * len(qa_ids))
query = f'DELETE FROM qa WHERE id IN ({placeholders})'
cursor.execute(query, qa_ids)
conn.commit()
conn.close()
return api_response(data={'message': '批量删除QA记录成功'})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
# v2 API路由 - 报告生成
@v2.route('/api/reports/daily')
def generate_daily_report():
"""生成日报API (v2版本,使用subscribe表)"""
try:
date_str = request.args.get('date', now().to_string(TimeUtils.FORMAT_DATE))
target_date, start_timestamp, end_timestamp = report_service.get_date_range(date_str)
yesterday_start, yesterday_end = report_service.get_previous_day_range(target_date)
# 使用ReportService类获取数据
today_message_count = report_service.get_message_count_by_time_range(start_timestamp, end_timestamp)
yesterday_message_count = report_service.get_message_count_by_time_range(yesterday_start, yesterday_end)
message_change_rate = report_service.calculate_change_rate(
today_message_count, yesterday_message_count
)
# 获取活跃用户数
today_active_users = report_service.get_active_sub_users_by_time_range(start_timestamp, end_timestamp)
yesterday_active_users = report_service.get_active_sub_users_by_time_range(yesterday_start, yesterday_end)
user_change_rate = report_service.calculate_change_rate(
today_active_users, yesterday_active_users
)
# 获取订阅和成员数量
subscribe_count = report_service.get_subscribe_count()
member_count = report_service.get_member_count()
# 获取QA记录数量
today_qa_count = report_service.get_qa_count_by_time_range_sub(start_timestamp, end_timestamp)
yesterday_qa_count = report_service.get_qa_count_by_time_range_sub(yesterday_start, yesterday_end)
non_qa_count = report_service.get_non_answer_qa_count_by_time_range_sub(start_timestamp, end_timestamp)
qa_change_rate = report_service.calculate_change_rate(
today_qa_count, yesterday_qa_count
)
# 获取订阅活跃度
subscribe_activity_data = report_service.get_subscribe_activity_data(start_timestamp, end_timestamp)
# 获取热点话题
recent_messages = report_service.get_recent_messages_by_time_range(start_timestamp, end_timestamp, 100)
hot_topics = report_service.analyze_hot_topics(recent_messages)
# 生成直方图
subscribe_activity_chart = report_service.generate_group_activity_chart(subscribe_activity_data)
hourly_activity_chart = report_service.generate_hourly_activity_chart(start_timestamp, end_timestamp)
# 生成日报
report_date = from_datetime(target_date).format_custom('%Y年%m月%d日 (%A)')
daily_report = f"""# **致远速联高端社群运营日报**
**报告周期**:{report_date}
**覆盖范围**:{subscribe_count}个订阅,总计成员 {member_count} 人
## **1. 核心数据概览**
| 指标 | 今日数据 | 昨日数据 | 变化 |
| :--- | :--- | :--- | :--- |
| 总消息量 | {today_message_count} 条 | {yesterday_message_count} 条 | {'↑' if message_change_rate >= 0 else '↓'}{abs(message_change_rate):.1f}% |
| 活跃人数 | {today_active_users} 人 | {yesterday_active_users} 人 | {'↑' if user_change_rate >= 0 else '↓'}{abs(user_change_rate):.1f}% |
| 问题新增记录 | {today_qa_count} 条 | {yesterday_qa_count} 条 | {'↑' if qa_change_rate >= 0 else '↓'}{abs(qa_change_rate):.1f}% |
## **2. 分订阅活跃度对比**
```
{subscribe_activity_chart}
```
## **3. 今日热点与时段**
### **热点问题TOP 3**:
"""
for i, topic in enumerate(hot_topics, 1):
daily_report += f" {i}. {topic}\n"
daily_report += f"""### **活跃时段**:消息高峰分布
```
{hourly_activity_chart}
```
## **4. 待办事项**
{"有" + str(non_qa_count) + "条未被回答的问题,关注活跃订阅的后续讨论,确保所有疑问被解答。" if non_qa_count > 0 else "无未被回答的问题,继续保持活跃订阅的讨论。"}
明日重点关注:根据今日热点预测明日可能的问题。
"""
return api_response(data={
'report': daily_report,
'date': date_str,
'statistics': {
'today_message_count': today_message_count,
'yesterday_message_count': yesterday_message_count,
'message_change_rate': message_change_rate,
'today_active_users': today_active_users,
'yesterday_active_users': yesterday_active_users,
'user_change_rate': user_change_rate,
'today_qa_count': today_qa_count,
'yesterday_qa_count': yesterday_qa_count,
'qa_change_rate': qa_change_rate,
'group_count': subscribe_count,
'member_count': member_count
}
})
except Exception as e:
return api_response(success=False, error=e, status_code=500)
@v2.route('/api/reports/weekly')
def generate_weekly_report():
"""生成周报API (v2版本,使用subscribe表)"""
try:
week_str = request.args.get('week', now().format_custom('%Y-%W'))
# 解析week_str参数,格式为YYYY-WW
year, week_num = map(int, week_str.split('-'))
# 根据周数计算时间范围
# 获取指定年份的第一天
first_day_of_year = datetime(year, 1, 1)
# 计算第一周的周一(ISO周从周一开始)
first_monday = first_day_of_year + timedelta(days=(7 - first_day_of_year.weekday()) % 7)
if first_day_of_year.weekday() <= 3: # 如果1月1日是周一到周四,则属于第一周
first_monday = first_day_of_year - timedelta(days=first_day_of_year.weekday())
# 计算指定周的周一
week_monday = first_monday + timedelta(weeks=week_num - 1)
# 计算指定周的周日
week_sunday = week_monday + timedelta(days=6)
start_date = week_monday.replace(hour=0, minute=0, second=0, microsecond=0)
end_date = week_sunday.replace(hour=23, minute=59, second=59, microsecond=999999)
start_timestamp = int(from_datetime(start_date).to_timestamp())
end_timestamp = int(from_datetime(end_date).to_timestamp())
# 获取上周同期数据
last_week_start = start_date - timedelta(weeks=1)
last_week_end = end_date - timedelta(weeks=1)
last_week_start_ts = int(from_datetime(last_week_start).to_timestamp())
last_week_end_ts = int(from_datetime(last_week_end).to_timestamp())
# 使用ReportService类获取数据
week_message_count = report_service.get_message_count_by_time_range(start_timestamp, end_timestamp)
last_week_message_count = report_service.get_message_count_by_time_range(last_week_start_ts, last_week_end_ts)
message_change_rate = report_service.calculate_change_rate(
week_message_count, last_week_message_count
)
# 获取活跃用户数
week_active_users = report_service.get_active_sub_users_by_time_range(start_timestamp, end_timestamp)
# 获取订阅和成员数量
subscribe_count = report_service.get_subscribe_count()
member_count = report_service.get_member_count()
# 获取QA记录数量
qa_count = report_service.get_qa_count_by_time_range(start_timestamp, end_timestamp)
non_answer_qa_count = report_service.get_non_answer_qa_count_by_time_range(start_timestamp, end_timestamp)
# 获取本周消息用于分类分析
week_messages = report_service.get_weekly_messages(start_timestamp, end_timestamp, 200)
category_breakdown = report_service.analyze_category_breakdown(week_messages)
# 生成周报
week_period = f"{from_datetime(start_date).format_custom('%Y年%m月%d日')} - {from_datetime(end_date).format_custom('%Y年%m月%d日')} (第{week_str.split('-')[1]}周)"
weekly_report = f"""# **致远速联高端社群运营周报**
**报告周期**:{week_period}
## **1. 本周摘要**
- 本周社群总消息量 **{week_message_count}条**,{'环比上升' if message_change_rate >= 0 else '环比下降'} **{abs(message_change_rate):.1f}%**。
- 活跃用户数达到 **{week_active_users}人**,社群参与度良好。
- 新增问题解答记录 **{qa_count}条**,知识沉淀效果显著。
- 其中,有 **{non_answer_qa_count}条** 未被回答,值得注意。
## **2. 深度洞察:上市公司提问分类**
"""
# 添加问题分类信息
for category, percentage in category_breakdown.items():
weekly_report += f"- {category}:{percentage}%\n"
weekly_report += f"""\n**洞察**:问题集中在信披和法规,说明成员对合规性高度重视。
## **3. 内容价值与知识沉淀**
- **本周高价值问答**:系统自动识别并记录了{qa_count}条有价值的问答记录。
- **建议**:将高频问答整理至知识库,便于后续参考。
"""
result = agent.chat_deepseek(weekly_report,
"根据下面的内容给出总结和建议,格式如下:\n## **4. 总结与建议**\n**总结**:\n**建议**:\n")
return api_response(data={
'report': weekly_report + "\n" + result,
'week': week_str,
'statistics': {
'week_message_count': week_message_count,
'last_week_message_count': last_week_message_count,
'message_change_rate': message_change_rate,
'week_active_users': week_active_users,
'group_count': subscribe_count,
'member_count': member_count,
'qa_count': qa_count,
'category_breakdown': category_breakdown
}
})
except Exception as e:
return api_response(success=False, error=e, status_code=500)

276
api/v3.py Normal file
View File

@@ -0,0 +1,276 @@
"""
主程序入口 - 集成周报生成器
"""
from datetime import datetime, timedelta
from . import Blueprint
from api.lib import db_manager
from weekly_report_generator import WeeklyReportGenerator
from flask import request, jsonify, render_template
v3 = Blueprint('v3', __name__)
# 初始化周报生成器
weekly_report_generator = None
def init_weekly_report_generator():
"""初始化周报生成器"""
global weekly_report_generator
if weekly_report_generator is None:
weekly_report_generator = WeeklyReportGenerator(db_manager)
@v3.route('/api/weekly_report/generate', methods=['GET', 'POST'])
def generate_weekly_report_api():
"""生成周报API接口 (v3版本,基于需求文档样式)"""
try:
init_weekly_report_generator()
# 获取参数
if request.method == 'POST':
data = request.get_json()
week_str = data.get('week')
use_deepseek = data.get('use_deepseek', True)
else:
week_str = request.args.get('week')
use_deepseek = request.args.get('use_deepseek', 'true').lower() == 'true'
# 如果没有提供周次,使用当前周
if not week_str:
current_date = datetime.now()
year = current_date.isocalendar()[0]
week_num = current_date.isocalendar()[1]
week_str = f"{year}-{week_num:02d}"
# 生成周报
result = weekly_report_generator.generate_weekly_report(week_str, use_deepseek)
if result['success']:
return jsonify({
'success': True,
'data': {
'report': result['report'],
'week': result['week'],
'statistics': result.get('statistics', {})
}
})
else:
return jsonify({
'success': False,
'error': result['error']
}), 500
except Exception as e:
return jsonify({
'success': False,
'error': f"生成周报失败: {str(e)}"
}), 500
@v3.route('/api/weekly_report/weeks', methods=['GET'])
def get_available_weeks_v3():
"""获取可用的周次列表 (v3版本)"""
try:
init_weekly_report_generator()
# 获取数据库中最早和最晚的消息时间
result = db_manager.execute_query('''
SELECT MIN(time) as min_time, MAX(time) as max_time
FROM message
WHERE LENGTH(time) >= 10
''', fetch_all=False)
if result and result['min_time'] and result['max_time']:
min_timestamp = int(result['min_time'][:10])
max_timestamp = int(result['max_time'][:10])
min_date = datetime.fromtimestamp(min_timestamp)
max_date = datetime.fromtimestamp(max_timestamp)
# 生成可用的周次列表
weeks = []
current_date = min_date
while current_date <= max_date:
year = current_date.isocalendar()[0]
week_num = current_date.isocalendar()[1]
week_str = f"{year}-{week_num:02d}"
# 计算周的开始和结束日期
start_of_week = current_date - timedelta(days=current_date.weekday())
end_of_week = start_of_week + timedelta(days=6)
weeks.append({
'week': week_str,
'start_date': start_of_week.strftime('%Y-%m-%d'),
'end_date': end_of_week.strftime('%Y-%m-%d'),
'display': f"{year}年第{week_num}周 ({start_of_week.strftime('%m.%d')}-{end_of_week.strftime('%m.%d')})"
})
current_date += timedelta(weeks=1)
return jsonify({
'success': True,
'data': {
'weeks': weeks[-20:] # 返回最近20周
}
})
else:
return jsonify({
'success': False,
'error': '无法获取时间范围数据'
}), 500
except Exception as e:
return jsonify({
'success': False,
'error': f"获取周次列表失败: {str(e)}"
}), 500
@v3.route('/api/weekly_report/preview', methods=['POST'])
def preview_weekly_report():
"""预览周报API接口 (v3版本)"""
try:
init_weekly_report_generator()
data = request.get_json()
week_str = data.get('week')
if not week_str:
return jsonify({
'success': False,
'error': '请提供周次参数'
}), 400
# 生成预览数据(不调用DeepSeek API)
result = weekly_report_generator.generate_weekly_report(week_str, use_deepseek=False)
if result['success']:
return jsonify({
'success': True,
'data': {
'preview': result['report'],
'statistics': result.get('statistics', {})
}
})
else:
return jsonify({
'success': False,
'error': result['error']
}), 500
except Exception as e:
return jsonify({
'success': False,
'error': f"预览周报失败: {str(e)}"
}), 500
@v3.route('/api/weekly_report/export', methods=['POST'])
def export_weekly_report():
"""导出周报API接口 (v3版本)"""
try:
init_weekly_report_generator()
data = request.get_json()
week_str = data.get('week')
export_format = data.get('format', 'markdown') # markdown, html, pdf
if not week_str:
return jsonify({
'success': False,
'error': '请提供周次参数'
}), 400
# 生成完整周报
result = weekly_report_generator.generate_weekly_report(week_str, use_deepseek=True)
if result['success']:
report_content = result['report']
# 根据格式处理内容
if export_format == 'html':
# 简单的Markdown转HTML(实际项目中可以使用专业库)
html_content = markdown_to_html(report_content)
return jsonify({
'success': True,
'data': {
'content': html_content,
'filename': f"周报_{week_str}.html",
'mime_type': 'text/html'
}
})
elif export_format == 'pdf':
# PDF导出(需要额外库支持)
return jsonify({
'success': False,
'error': 'PDF导出功能暂未实现'
}), 501
else: # markdown
return jsonify({
'success': True,
'data': {
'content': report_content,
'filename': f"周报_{week_str}.md",
'mime_type': 'text/markdown'
}
})
else:
return jsonify({
'success': False,
'error': result['error']
}), 500
except Exception as e:
return jsonify({
'success': False,
'error': f"导出周报失败: {str(e)}"
}), 500
def markdown_to_html(markdown_text: str) -> str:
"""简单的Markdown转HTML函数"""
# 基本转换规则
html = markdown_text
# 标题转换
html = html.replace('\n# ', '\n<h1>').replace('\n## ', '\n<h2>').replace('\n### ', '\n<h3>')
html = html.replace('\n#### ', '\n<h4>').replace('\n##### ', '\n<h5>').replace('\n###### ', '\n<h6>')
# 表格处理
html = html.replace('\t', '</td><td>')
html = html.replace('\n', '</td></tr>\n<tr><td>')
# 基本的HTML包装
html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>周报</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; }}
table {{ border-collapse: collapse; width: 100%; }}
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
th {{ background-color: #f2f2f2; }}
</style>
</head>
<body>
{html}
</body>
</html>"""
return html
# v3周报页面路由
@v3.route('/weekly_report')
def weekly_report_page():
"""周报页面 v3版本"""
return render_template('weekly_report_v3.html')

18
app.py Normal file
View File

@@ -0,0 +1,18 @@
import os
from api import app
from api.lib import db_manager
if __name__ == '__main__':
# 检查数据库文件是否存在
if not os.path.exists('db/data'):
print(f"错误: 数据库文件不存在")
print("正在下载云端数据库")
db_manager.download_db()
print("下载完成")
print("启动聊天网页服务...")
print("访问地址: http://localhost:9000")
print("问答页面: http://localhost:9000/qa")
print("周报页面: http://localhost:9000/weekly_report")
print("日报页面: http://localhost:9000/daily_report")
app.run(debug=True, host='0.0.0.0', port=9000)

735
chat_session.py Normal file
View File

@@ -0,0 +1,735 @@
import datetime
import json
import os.path
from typing import List, Tuple
import logging
import os
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
import pytz
from tools import DatabaseManager
from file import ObjectFileManager, LocalFileManager
TZ = pytz.timezone('Asia/Shanghai')
def convert_to_utc(time_str, time_format='%Y-%m-%d %H:%M:%S', source_timezone=None):
# 解析时间字符串为datetime对象
local_dt = datetime.datetime.strptime(time_str, time_format)
# 如果指定了源时区,则将本地时间转换为UTC时间
if source_timezone:
tz = pytz.timezone(source_timezone)
local_dt = tz.localize(local_dt)
utc_dt = local_dt.astimezone(pytz.UTC)
else:
# 如果没有指定源时区,则假设时间字符串已经是UTC时间
utc_dt = pytz.UTC.localize(local_dt)
return utc_dt
class AIAgent:
"""AI代理类,整合了各种AI功能"""
def __init__(self, deepseek_api_key: str = "sk-c2f04b4d635b4df0aace0432f3c6d6f4",
max_retries: int = 3, timeout: int = 30):
"""初始化AI助手
Args:
deepseek_api_key: DeepSeek API密钥
max_retries: 最大重试次数
timeout: 请求超时时间(秒)
"""
self.deepseek_api_key = deepseek_api_key
self.timeout = timeout
# 配置日志
self._setup_logging()
# 创建带重试机制的会话
self.session = self._create_session(max_retries)
# 配置常量
self.BASE_REPORT_URL = "https://a.cmdp.cn/basiceg/v1/json/tpldev"
self.CACHE_DIR = "cache"
def _setup_logging(self):
"""配置日志"""
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
self.logger = logging.getLogger(__name__)
@staticmethod
def _create_session(max_retries: int) -> requests.Session:
"""创建带重试机制的会话
Args:
max_retries: 最大重试次数
Returns:
配置好的会话对象
"""
session = requests.Session()
# 配置重试策略
retry_strategy = Retry(
total=max_retries,
status_forcelist=[429, 500, 502, 503, 504],
backoff_factor=1
)
# 为HTTP和HTTPS适配器添加重试机制
adapter = HTTPAdapter(max_retries=retry_strategy)
session.mount("http://", adapter)
session.mount("https://", adapter)
return session
def _make_request(self, method: str, url: str, **kwargs):
"""发送HTTP请求(带错误处理)
Args:
method: HTTP方法
url: 请求URL
**kwargs: 其他请求参数
Returns:
JSON响应或None
"""
try:
# 设置默认超时
if 'timeout' not in kwargs:
kwargs['timeout'] = self.timeout
response = self.session.request(method, url, **kwargs)
response.raise_for_status()
self.logger.info(f"请求成功: {url}")
return response.json()
except requests.exceptions.RequestException as e:
self.logger.error(f"请求失败: {url} - {str(e)}")
return {}
except json.JSONDecodeError as e:
self.logger.error(f"JSON解析失败: {url} - {str(e)}")
return {}
def chat_deepseek(self, message: str, role: str = "", model: str = "deepseek-chat") -> str:
"""与DeepSeek聊天
Args:
message: 用户消息
role: 系统角色
model: 使用的模型
Returns:
AI回复内容
"""
url = "https://api.deepseek.com/chat/completions"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.deepseek_api_key}"
}
messages = []
if role:
messages.append({"role": "system", "content": role})
messages.append({"role": "user", "content": message})
body = {
"model": model,
"messages": messages,
"stream": False
}
result = self._make_request("POST", url, json=body, headers=headers)
if result and "choices" in result:
return result.get('choices', [{}])[0]['message']['content']
self.logger.warning("DeepSeek API调用失败或返回空结果")
return ""
agent = AIAgent(max_retries=1, timeout=180)
class ChatSessionManager:
"""聊天会话管理器类"""
def __init__(self, db_manager: DatabaseManager):
"""
初始化聊天会话管理器
Args:
db_manager: 数据库管理工具
"""
self.files_dir = os.path.join("", "files")
self.messages_dir = os.path.join("", "messages")
self.cache_path = None
# 初始化文件管理器
self.obj = ObjectFileManager()
self.lfm = LocalFileManager(self.files_dir)
self.dbm = db_manager
self.users = []
self.groups = []
self.members = {}
self.messages = []
self.files = {}
# 确保目录存在
os.makedirs(self.files_dir, exist_ok=True)
os.makedirs(self.messages_dir, exist_ok=True)
def execute_query(self, query: str, params=None, fetch_all=True):
result = []
if fetch_all:
rows = self.dbm.execute_query(query, params)
for row in rows:
result.append(list(row))
else:
rows = self.dbm.execute_query(query, params, False)
result = list(rows)
return result
def execute_event(self, query: str, params=None):
return self.dbm.execute_event(query, params)
def status(self):
row = self.dbm.execute_query("SELECT datetime FROM information order by datetime desc limit 1", fetch_all=False)
if row:
return row[0]
else:
return None
def _download_file(self, file_info, files_dir, history, lock):
"""
下载单个文件的线程函数
Args:
file_info: 文件信息
files_dir: 文件保存目录
history: 聊天历史字典
lock: 线程锁
"""
try:
path = file_info["path"].replace("s3://", "")
file_content = self.obj.get_file(path)
if path.endswith(".json"):
data = json.loads(file_content)
with lock:
history[data["msgid"]] = data
else:
# 保存非JSON文件
filename = path.split("/")[-1]
with open(os.path.join(files_dir, filename), "wb") as f:
f.write(file_content)
return path, True
except Exception as e:
return path, False
def download_chat_history(self, year: int, month: int, day: int, hour: int, max_workers: int = 5) -> bool:
"""
下载指定时间的聊天历史(多线程版本)
Args:
year: 年份
month: 月份
day: 日期
hour: 小时
max_workers: 最大线程数
Returns:
下载是否成功
"""
message_history_name = f"{year}_{month:02d}_{day:02d}_{hour:02d}.json"
print(f"Downloading chat history for {year}-{month:02d}-{day:02d}-{hour:02d} (多线程模式)")
try:
file_list = self.obj.get_file_list(f"zysl-log/wework/chat_msg/{year}/{month}/{day}/{hour}/")
print(f"共计{len(file_list)}个文件,使用{max_workers}个线程下载")
history = {}
failed_files = []
if file_list:
lock = threading.Lock()
# 使用线程池下载文件
with ThreadPoolExecutor(max_workers=max_workers) as executor:
# 提交所有下载任务
future_to_file = {
executor.submit(self._download_file, file_info, self.files_dir, history, lock): file_info
for file_info in file_list
}
# 处理完成的任务
completed_count = 0
total_count = len(file_list)
for future in as_completed(future_to_file):
file_info = future_to_file[future]
completed_count += 1
try:
path, success = future.result()
if success:
print(f"[{completed_count}/{total_count}] 下载成功: {path}")
else:
print(f"[{completed_count}/{total_count}] 下载失败: {path}")
failed_files.append(path)
except Exception as e:
print(f"[{completed_count}/{total_count}] 处理异常: {file_info['path']} - {str(e)}")
failed_files.append(file_info['path'])
# 检查是否有失败的文件
if failed_files:
print(f"警告: {len(failed_files)}个文件下载失败")
print("失败文件列表:", failed_files)
# 保存聊天历史JSON文件
output_path = os.path.join(self.messages_dir, message_history_name)
with open(output_path, "w", encoding="utf-8") as f:
json.dump(history, f, ensure_ascii=False, indent=2)
print(f"Downloading chat history for {year}-{month:02d}-{day:02d}-{hour:02d} complete")
print(f"成功下载: {len(file_list) - len(failed_files)}/{len(file_list)} 个文件")
# 如果有失败的文件,返回False
return len(failed_files) == 0
except Exception as e:
error_file = os.path.join(self.messages_dir, f"{message_history_name}.unfinished")
with open(error_file, "w", encoding="utf-8") as f:
f.write(str(e))
print(
f"Downloading chat history for {year}-{month:02d}-{day:02d}-{hour:02d} failed: please check the {message_history_name}.unfinished")
return False
def _parse_message(self, data, is_mixed: bool = False, idx: int = 0):
msg_type = data["msgtype"]
if msg_type == "text":
content = data['text']["content"]
elif msg_type in ["file", "image", "video", "voice", "emotion", "meeting_voice_call"]:
file_data = data[msg_type]
suffixs = [f"_{idx}", ".png", ".jpg", ".gif", ".mp4", "_.png", "_.jpg", "_.gif", "_.mp4",
("_" + file_data["filename"]) if msg_type == "file" else ""]
file_path = ""
for suffix in suffixs:
temp_path = f"{data['msgid']}_mixed{suffix}" if is_mixed else f"{data['msgid']}_{msg_type}{suffix}"
if self.lfm.exists(temp_path):
file_path = temp_path
break
if file_path != "":
ext_mapping = {
"file": (file_data.get('fileext', ''), "filesize"),
"image": ("png", file_data.get("filesize", 0)),
"video": ("mp4", file_data.get("filesize", 0)),
"voice": ("wav", file_data.get("voice_size", 0)),
"emotion": ("gif" if file_data.get("type", 0) == 1 else "png", file_data.get("imagesize", 0)),
"meeting_voice_call": ("wav", self.lfm.get_size(file_path)),
}
file_info = {
"id": file_data.get("md5sum", self.lfm.get_md5(file_path)),
"size": ext_mapping[msg_type][1],
"extension": ext_mapping[msg_type][0],
"content": file_path,
"original": self.cache_path + file_path,
"type": msg_type
}
self.files[file_info["id"]] = file_info
content = file_info["id"]
else:
file_path = f"{data['msgid']}_{msg_type}_0"
file_info = {
"id": file_data.get("md5sum", file_path),
"size": file_data.get('filesize', 0),
"extension": file_data.get('fileext', "unknown"),
"content": file_path,
"original": self.cache_path + file_path,
"type": msg_type
}
self.files[file_info["id"]] = file_info
content = file_info["id"]
elif msg_type == "link":
data = data[msg_type]
content = f'{data["description"]}{data["title"]}({data["link_url"]})'
else:
print(f"未知消息类型: {msg_type}")
content = json.dumps(data, ensure_ascii=False)
return content
def _parse_mixed_message(self, param, idx: int):
tp = param["msgtype"]
content = self._parse_message(param, True, idx)
if tp in ["file", "image", "video", "voice", "emotion", "meeting_voice_call"]:
self.files[content]["content"] = f'{param["msgid"]}_mixed_{idx}'
idx += 1
return idx, content
def parse_chat_history(self, year: int, month: int, day: int, hour: int):
"""
解析聊天历史数据
Args:
year: 年份
month: 月份
day: 日期
hour: 小时
Returns:
(用户列表, 群组列表, 成员映射, 消息列表, 文件映射)
"""
message_history_name = f"{year}_{month:02d}_{day:02d}_{hour:02d}.json"
history_path = os.path.join(self.messages_dir, message_history_name)
# 如果文件不存在,先下载
if not os.path.exists(history_path):
self.download_chat_history(year, month, day, hour,10)
with open(history_path, "r", encoding="utf-8") as f:
message_history = json.load(f)
try:
for message_data in message_history.values():
# 只处理发送的消息
if message_data.get("action") != "send":
continue
# 提取基本信息
from_user = message_data["from"]
to_users = message_data["tolist"]
room_id = message_data.get("roomid", "")
# 更新用户列表
self.users.extend([from_user] + to_users)
self.users = list(set(self.users))
# 确定接收者类型
to_type = 1 if room_id else 0
receiver = room_id if to_type == 1 else to_users[0]
# 处理群组信息
if room_id:
if room_id not in self.members:
self.members[room_id] = to_users + [from_user]
else:
self.members[room_id].extend(to_users + [from_user])
self.members[room_id] = list(set(self.members[room_id]))
if room_id not in self.groups:
self.groups.append(room_id)
# 构建消息对象
message = {
"id": message_data["msgid"],
"type": message_data["msgtype"],
"time": message_data["msgtime"],
"toType": to_type,
"from": from_user,
"to": receiver
}
# 根据消息类型处理内容
msg_type = message_data["msgtype"]
if msg_type == "mixed":
temp = []
idx = 0
for t in message_data["mixed"]["item"]:
idx, content = self._parse_mixed_message({"msgid": message_data["msgid"],
"msgtype": t["type"],
t["type"]: json.loads(t["content"])}, idx)
temp.append([t["type"], content])
message["content"] = json.dumps(temp, ensure_ascii=False)
else:
message["content"] = self._parse_message(message_data)
self.messages.append(message)
except Exception as e:
print(f"解析聊天记录失败: {e}")
raise e
def save_to_sqlite(self, sql: str, data: List[Tuple]) -> None:
"""
批量保存数据到SQLite数据库
Args:
sql: SQL语句
data: 数据列表
"""
if not data:
return
print(f"开始插入 {len(data)} 条记录")
self.dbm.execute_event(sql, data)
print(f"成功插入 {len(data)} 条记录")
def save_information_to_sqlite(self, date_time: str, message_num: int, file_num: int, user_num: int,
group_num: int) -> None:
"""
保存会话信息到SQLite数据库
Args:
date_time: 会话时间
message_num: 消息数量
file_num: 文件数量
user_num: 用户数量
group_num: 群组数量
"""
self.save_to_sqlite('''
INSERT OR IGNORE INTO "information"
(datetime, message, file, user, "group")
VALUES (?, ?, ?, ?, ?)
''', [(date_time, message_num, file_num, user_num, group_num)])
def save_all_to_sqlite(self) -> None:
"""
保存所有聊天数据到SQLite数据库
"""
# 保存文件信息
file_data = []
for file_info in self.files.values():
file_data.append((
file_info.get('id'),
file_info.get('type'),
file_info.get('content'),
file_info.get('original', ''),
file_info.get('extension', ''),
file_info.get('size', 0)
))
self.save_to_sqlite('''
INSERT OR IGNORE INTO file
(id, type, content, original, extension, size)
VALUES (?, ?, ?, ?, ?, ?)
''', file_data)
# 保存用户信息
user_data = [(user, user) for user in self.users]
self.save_to_sqlite('''
INSERT OR IGNORE INTO user
(id, name)
VALUES (?, ?)
''', user_data)
# 保存消息信息
message_data = []
for message in self.messages:
message_data.append((
message.get('id'),
message.get('type'),
message.get('time'),
message.get('toType'),
message.get('from'),
message.get('to'),
message.get('content')
))
self.save_to_sqlite('''
INSERT OR IGNORE INTO message
(id, type, time, toType, sender, receiver, content)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', message_data)
# 保存群组信息
group_data = [(group, group) for group in self.groups]
self.save_to_sqlite('''
INSERT OR IGNORE INTO "group"
(id, name)
VALUES (?, ?)
''', group_data)
# 保存成员信息
member_data = []
for group_id, group_users in self.members.items():
for user in group_users:
member_data.append((group_id, user))
self.save_to_sqlite('''
INSERT OR IGNORE INTO "member"
(groupId, userId)
VALUES (?, ?)
''', member_data)
def process_chat_session(self, year: int, month: int, day: int, hour: int) -> bool:
"""
处理完整的聊天会话流程
Args:
year: 年份
month: 月份
day: 日期
hour: 小时
Returns:
处理是否成功
"""
self.users = []
self.groups = []
self.members = {}
self.messages = []
self.files = {}
try:
self.cache_path = f"zysl-log/wework/chat_msg/{year}/{month}/{day}/{hour}/"
# 解析聊天历史
self.parse_chat_history(year, month, day, hour)
# 保存到数据库
self.save_all_to_sqlite()
self.save_information_to_sqlite(f"{year}-{month:02d}-{day:02d}-{hour:02d}", len(self.messages),
len(self.files), len(self.users), len(self.groups))
print(f"成功处理 {year}-{month:02d}-{day:02d}-{hour:02d} 的聊天会话")
return True
except Exception as e:
print(f"处理聊天会话失败: {e}")
return False
def get_chat_history(self, start_time_str: str, end_time_str: str) -> list:
"""
获取指定时间的聊天历史记录
Args:
start_time_str: 开始时间,格式为 "YYYY-MM-DD HH:MM:SS"
end_time_str: 结束时间,格式为 "YYYY-MM-DD HH:MM:SS"
Returns:
聊天历史记录列表
"""
start_time = convert_to_utc(start_time_str, source_timezone="Asia/Shanghai").timestamp()
end_time = convert_to_utc(end_time_str, source_timezone="Asia/Shanghai").timestamp()
res = self.dbm.execute_query('''
SELECT message.time, message.content
FROM message
WHERE (message.type = 'text'
or message.type = 'mixed')
AND message.time >= ?
AND message.time <= ?
''', (str(start_time).replace(".0", "000"), str(end_time).replace(".0", "000")))
return res
@staticmethod
def analyze_qa_content(group_messages) -> dict:
group_qa = {}
for group_id in group_messages:
print(f"群ID: {group_id}")
print(f"消息数量: {len(group_messages[group_id])}")
content = ""
if len(group_messages[group_id]) <= 2:
continue
else:
message_mapping = {}
for msg in sorted(group_messages[group_id], key=lambda i: i[1]):
message_mapping[msg[4]] = msg[2]
content += json.dumps({
"user": msg[1],
"time": msg[2],
"content": msg[3],
"message_id": msg[4]
}, ensure_ascii=False) + ",\n"
template = f"""
以下是群中的消息记录
```json
[
{content}
]
```
""" + """
找到其中的问题和回答,如果没有回答就只整理问题,问题是与产品填报系统,上市公司,证券政策相关的问题
问题可能不只由一句话构成,回答也可能不只由一句话构成
多个问题需要分开放置
不需要任何解释说明只返回json格式
输出格式
```json
[
{
"question": ["message_id",...],
"answer": ["message_id",...]
}
]
```
"""
answer = agent.chat_deepseek(template)
answer = answer.replace("```json", "").replace("```", "") if "```json" in answer else answer
try:
answer = json.loads(answer)
except:
print(answer)
answer = []
for ans in answer:
time_list = [message_mapping.get(a, "") for a in ans["answer"]] + [message_mapping.get(q, "") for q
in ans["question"]]
start_time = min(time_list)
end_time = max(time_list)
ans["start_time"] = start_time
ans["end_time"] = end_time
group_qa[group_id] = answer
return group_qa
def save_qa_to_sqlite(self, group_qa: dict):
"""
将问答数据保存到 SQLite 数据库
Args:
group_qa: 问答数据字典,键为群ID,值为问答列表
"""
data = []
index_id = self.execute_query(f"SELECT id FROM qa order by id desc limit 1", fetch_all=False)[0]
for r in group_qa:
qas = group_qa.get(r, {})
for qa in qas:
Q = qa["question"]
A = qa["answer"]
index_id += 1
data.append((index_id, r, json.dumps(Q, ensure_ascii=False), json.dumps(A, ensure_ascii=False),
qa["start_time"],
qa["end_time"]))
self.execute_event('''
insert into qa (id, groupId, question, answer, start_time, end_time)
values (?, ?, ?, ?, ?, ?)
''', data)
def update_qa_data(self, start_time_str: str, end_time_str: str):
"""
更新指定时间范围内的问答内容
Args:
start_time_str: 开始时间,格式为 "YYYY-MM-DD HH:MM:SS"
end_time_str: 结束时间,格式为 "YYYY-MM-DD HH:MM:SS"
"""
start_time = convert_to_utc(start_time_str, source_timezone="Asia/Shanghai").timestamp()
end_time = convert_to_utc(end_time_str, source_timezone="Asia/Shanghai").timestamp()
data = self.execute_query('''
SELECT receiver, sender, time, content, id
FROM message
WHERE message.time >= ?
AND message.time <= ?
AND toType = 1
AND type in ('text', 'mixed', 'chatrecord')
''', (str(start_time).replace(".0", "000"), str(end_time).replace(".0", "000")))
group_messages = {}
for i in data:
group_id = i[0]
if group_id not in group_messages:
group_messages[group_id] = []
group_messages[group_id].append(i)
group_qa = self.analyze_qa_content(group_messages)
self.save_qa_to_sqlite(group_qa)

196
file.py Normal file
View File

@@ -0,0 +1,196 @@
import abc
import hashlib
import os
import random
from abc import ABC
import requests
import json
class BaseFileManager(ABC):
@staticmethod
def _post(url: str, body: dict, headers: dict = None):
if headers is None:
headers = {}
resp = requests.post(url, json=body, headers=headers)
if resp.status_code != 200:
return {}
return resp.json()
@staticmethod
def _put(url: str, raw: bytes, headers: dict = None):
if headers is None:
headers = {}
resp = requests.put(url, data=raw, headers=headers)
if resp.status_code not in [201, 200]:
return {}
return resp.json()
@staticmethod
def _get(url: str, headers: dict = None):
if headers is None:
headers = {}
resp = requests.get(url, headers=headers)
if resp.status_code != 200:
return b""
return resp.content
@staticmethod
def _md5(content: str):
return hashlib.md5(content.encode("utf-8")).hexdigest()
@abc.abstractmethod
def upload_file(self, file_path: str, file_content: bytes):
pass
@abc.abstractmethod
def get_file(self, file_path: str):
pass
class ObjectFileManager(BaseFileManager):
def __init__(self, endpoint: str = 'https://a.cmdp.cn/basiceg/v1/bytes'):
self.endpoint = endpoint
def create_ticket(self, file_path: str, file_name: str) -> str:
url = "https://a.cmdp.cn/v1/cloudfile/file/createTicket"
body = {
"appId": "",
"uri": f"s3://{file_path}",
"startTime": "",
"endTime": "",
"maxTimes": 0,
"uid": "",
"name": file_name,
"isPublic": False
}
response = self._post(url, body)
return response.get("data", "")
def upload_file(self, file_path: str, file_content: bytes):
url = f"{self.endpoint}/{file_path}"
response = self._put(url, file_content)
return response
def get_file(self, file_path: str):
url = f"{self.endpoint}/{file_path}"
response = self._get(url)
return response
@staticmethod
def _get_file_list_info(file_path: str, page_index: int = 1, page_size: int = 1):
url = "https://openapi.genesysinfo.net/gate/09/sys/key/list"
payload = json.dumps({
"path": f"s3://{file_path}",
"pageIndex": page_index,
"pageSize": page_size
})
headers = {
'Content-Type': 'application/json'
}
response = requests.request("POST", url, headers=headers, data=payload)
if response.status_code != 200:
return {}
return response.json()
def get_file_list(self, file_path: str):
result = []
file_list_info = self._get_file_list_info(file_path)
total = file_list_info['totalRecords']
for i in range(int(total / 500) + 1):
result += self._get_file_list_info(file_path, page_index=i + 1, page_size=500)['modelList']
return result
def get_3party_file(self, url: str):
response = self._get(url)
return response
def transfer_file(self, url: str, file_path: str, file_type: str):
file = self.get_3party_file(url)
file_name = self._md5(url)
file_info = self.upload_file(f"{file_path}/{file_name}.{file_type}", file)
temp_path = file_info.get("file", "")
return temp_path
def transfer_ticket(self, url: str, file_path: str, file_type: str):
temp_path = self.transfer_file(url, file_path, file_type)
if temp_path == "":
return ""
return self.create_ticket(temp_path, temp_path.split("/")[-1])
class LocalFileManager(BaseFileManager):
def __init__(self, endpoint: str):
os.makedirs(endpoint, exist_ok=True)
self.endpoint = endpoint
def _entire_path(self, path: str):
return os.path.join(self.endpoint, path)
@staticmethod
def _exists(file_path: str):
return os.path.exists(file_path)
@staticmethod
def _read(file_path: str):
with open(file_path, "rb") as f:
content = f.read()
return content
@staticmethod
def _write(file_path: str, file_content: bytes):
try:
with open(file_path, "wb") as f:
f.write(file_content)
return True
except Exception as e:
print(e)
return False
def exists(self, file_path: str):
return self._exists(self._entire_path(file_path))
def get_size(self, file_path: str):
file_path = self._entire_path(file_path)
if not self._exists(file_path):
return 0
return os.path.getsize(file_path)
def get_md5(self, file_path: str):
file_path = self._entire_path(file_path)
md5_hash = hashlib.md5()
with open(file_path, "rb") as f:
# 分块读取大文件,避免内存溢出
for chunk in iter(lambda: f.read(4096), b""):
md5_hash.update(chunk)
md5_checksum = md5_hash.hexdigest()
return md5_checksum
def upload_file(self, file_path: str, file_content: bytes):
entire_path = self._entire_path(file_path)
return self._write(entire_path, file_content)
def get_file(self, file_path: str):
entire_path = self._entire_path(file_path)
return self._read(entire_path)
class OptionChoice:
def __init__(self, choices: list):
self.choices = choices
self.last_choices = []
def _update_last_choices(self, choices):
if len(self.last_choices) >= 39:
self.last_choices = self.last_choices[3:]
self.last_choices += choices
def choose(self, num: int) -> list:
temp = random.choices(self.choices, k=num)
return temp

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
mammoth
pytz
flask
requests

151
script.py Normal file
View File

@@ -0,0 +1,151 @@
import json
from datetime import datetime, timedelta
from api.lib import DatabaseManager
from chat_session import ChatSessionManager
apk = "wx98dfa35ad7e4b271"
aps = "MeudTZMWrJlyRMXrIrbo5QqHeLeCb_dzSueYiaawKR4"
caps = "Vt7mgU5cWc3NCeiFgwi4T8oOv_xShQLkYrqavhMy_AM"
import requests
def get_token(key, secret):
resp = requests.post("https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + key + "&corpsecret=" + secret)
result = resp.json()
return result["access_token"]
at = get_token(apk, aps)
cat = get_token(apk, caps)
def get_group_list(users: list):
resp = requests.post("https://qyapi.weixin.qq.com/cgi-bin/externalcontact/groupchat/list?access_token=" + at, json={
"status_filter": 0,
"owner_filter": {
"userid_list": users
},
"limit": 1000
})
result = resp.json()
print(result)
return result["group_chat_list"]
def get_inner_group_info(gid: str):
resp = requests.post("https://qyapi.weixin.qq.com/cgi-bin/msgaudit/groupchat/get?access_token=" + cat, json={
"roomid": gid
})
result = resp.json()
print(result)
return result["group_chat"]
def get_group_info(group_list):
mapping = {}
for gid in group_list:
print(gid)
url = "https://qyapi.weixin.qq.com/cgi-bin/externalcontact/groupchat/get?access_token=" + at
resp = requests.post(url, json={
"chat_id": gid,
"need_name": 1
})
result = resp.json()
print(result)
if "group_chat" in result:
mapping[gid] = result["group_chat"]
else:
mapping[gid] = result['errcode']
with open("groupchat.json", "w", encoding="utf-8") as f:
json.dump(mapping, f, ensure_ascii=False, indent=4)
def update_user_group_info():
dm = DatabaseManager('db/data', online_db_uri="zysl-log/wework/chat_msg/db/data.db")
conn = dm.get_connection()
cursor = conn.cursor()
m = cursor.execute("select id from 'group' where id = name").fetchall()
group_list = []
for gid in m:
gid = gid[0]
group_list.append(gid)
get_group_info(group_list)
with open("groupchat.json", "r", encoding="utf-8") as f:
mapping = json.load(f)
for gid, info in mapping.items():
if isinstance(info, int):
print(gid, info)
for gid in mapping:
if isinstance(mapping[gid], int):
print(gid, mapping[gid])
else:
cursor.execute("update 'group' set name = ?, owner = ? where id = ?",
(mapping[gid]["name"], mapping[gid]["owner"], gid))
cursor.execute("insert or ignore into 'group' (id, name, owner) values (?, ?, ?)",
(gid, mapping[gid]["name"], mapping[gid]["owner"]))
print(mapping[gid]["member_list"])
for member in mapping[gid]["member_list"]:
cursor.execute("insert or ignore into 'user' (id,name,nick_name,unionid) values (?, ?,?, ?)",
(member['userid'], member['name'], member['group_nickname'], member.get('unionid', '')))
cursor.execute("update 'user' set name=?,nick_name=?,unionid = ? where id = ?",
(member['name'], member['group_nickname'], member.get('unionid', ''), member['userid']))
conn.commit()
def generate_hourly_intervals(start_time_str):
"""
生成从开始时间到结束时间之间的每一个小时节点
时间格式: "YYYY-MM-DD-HH"
"""
# 解析时间字符串
start_parts = start_time_str.split("-")
end_parts = datetime.today().strftime("%Y-%m-%d-%H").split("-")
# 创建datetime对象
start_dt = datetime(int(start_parts[0]), int(start_parts[1]), int(start_parts[2]), int(start_parts[3]))
end_dt = datetime(int(end_parts[0]), int(end_parts[1]), int(end_parts[2]), int(end_parts[3]))
# 计算时间差(小时)
time_diff = end_dt - start_dt
total_hours = int(time_diff.total_seconds() / 3600)
# 生成每个小时节点
current_dt = start_dt
hour_intervals = []
for i in range(total_hours + 1):
hour_str = current_dt.strftime("%Y-%m-%d-%H")
hour_intervals.append(hour_str)
# 移动到下一个小时
current_dt += timedelta(hours=1)
return hour_intervals[1:]
if __name__ == '__main__':
db_manager = DatabaseManager('db/data', online_db_uri="zysl-log/wework/chat_msg/db/data.db")
# update messages info
chat_session = ChatSessionManager(db_manager)
result = \
db_manager.execute_query("select datetime from 'information' order by datetime desc limit 1", fetch_all=True)[0]
datetime_str = result["datetime"]
intervals = generate_hourly_intervals(datetime_str)
start_year, start_month, start_day, start_hour = intervals[0].split("-")
end_year, end_month, end_day, end_hour = intervals[-1].split("-")
for interval in intervals:
year, month, day, hour = interval.split("-")
chat_session.process_chat_session(int(year), int(month), int(day), int(hour))
chat_session.update_qa_data(f"{start_year}-{start_month}-{start_day} {start_hour}:00:00",
f"{end_year}-{end_month}-{end_day} {end_hour}:59:59")
# update user group info
update_user_group_info()
# upload db
db_manager.upload_db()

358
static/css/daily_report.css Normal file
View File

@@ -0,0 +1,358 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 10px;
margin-bottom: 30px;
text-align: center;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.header p {
font-size: 1.2em;
opacity: 0.9;
}
.controls {
background: white;
padding: 20px;
border-radius: 10px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.control-group {
display: flex;
gap: 20px;
align-items: center;
flex-wrap: wrap;
}
.control-group label {
font-weight: bold;
color: #555;
}
.control-group input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
}
.btn {
background: #667eea;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.btn:hover {
background: #5a6fd8;
}
.btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.report-container {
background: white;
border-radius: 10px;
padding: 30px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
}
.report-content {
white-space: pre-wrap;
font-family: 'Courier New', monospace;
line-height: 1.8;
font-size: 14px;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.error {
background: #ffe6e6;
color: #d63031;
padding: 15px;
border-radius: 5px;
margin: 10px 0;
border-left: 4px solid #d63031;
}
.statistics {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-top: 20px;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.stat-item {
background: white;
padding: 15px;
border-radius: 8px;
text-align: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.stat-value {
font-size: 1.8em;
font-weight: bold;
color: #667eea;
}
.stat-label {
font-size: 0.9em;
color: #666;
margin-top: 5px;
}
.nav {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.nav-btn {
padding: 8px 16px;
background: #f8f9fa;
border: 1px solid #ddd;
border-radius: 5px;
text-decoration: none;
color: #333;
transition: all 0.3s;
}
.nav-btn:hover {
background: #667eea;
color: white;
border-color: #667eea;
}
.export-btn {
background: #28a745;
color: white;
border: none;
padding: 8px 16px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
margin-left: auto;
}
.export-btn:hover {
background: #218838;
}
/* 预览控制样式 */
.preview-controls {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 1px solid #eee;
padding-bottom: 15px;
}
.preview-btn {
background: #f8f9fa;
color: #666;
border: 1px solid #ddd;
padding: 8px 16px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.preview-btn:hover {
background: #e9ecef;
border-color: #adb5bd;
}
.preview-btn.active {
background: #667eea;
color: white;
border-color: #667eea;
}
/* Markdown源代码显示样式 */
.markdown-source {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 5px;
padding: 20px;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
max-height: 600px;
overflow-y: auto;
}
.markdown-source pre {
margin: 0;
background: none;
border: none;
padding: 0;
}
.markdown-source code {
background: none;
border: none;
padding: 0;
color: #333;
}
/* 增强的报告内容样式 */
.report-content {
white-space: pre-wrap;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.8;
font-size: 14px;
background: white;
padding: 20px;
border-radius: 5px;
border: 1px solid #e9ecef;
}
/* Markdown渲染样式 */
.report-content h1,
.report-content h2,
.report-content h3,
.report-content h4,
.report-content h5,
.report-content h6 {
margin: 20px 0 10px 0;
color: #333;
font-weight: 600;
}
.report-content h1 {
font-size: 1.8em;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
}
.report-content h2 {
font-size: 1.5em;
border-bottom: 1px solid #ddd;
padding-bottom: 8px;
}
.report-content h3 {
font-size: 1.3em;
}
.report-content p {
margin: 10px 0;
}
.report-content ul,
.report-content ol {
margin: 10px 0 10px 20px;
}
.report-content li {
margin: 5px 0;
}
.report-content blockquote {
border-left: 4px solid #667eea;
background: #f8f9fa;
padding: 10px 15px;
margin: 15px 0;
font-style: italic;
}
.report-content code {
background: #f1f3f4;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.report-content pre {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 5px;
padding: 15px;
overflow-x: auto;
margin: 15px 0;
}
.report-content pre code {
background: none;
padding: 0;
}
.report-content table {
border-collapse: collapse;
width: 100%;
margin: 15px 0;
}
.report-content th,
.report-content td {
border: 1px solid #ddd;
padding: 8px 12px;
text-align: left;
}
.report-content th {
background: #f8f9fa;
font-weight: 600;
}
.report-content tr:nth-child(even) {
background: #f8f9fa;
}
/* 水平分隔线样式 */
.report-content hr {
border: none;
border-top: 2px solid #667eea;
margin: 30px 0;
height: 0;
}

471
static/css/index.css Normal file
View File

@@ -0,0 +1,471 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 15px;
margin-bottom: 30px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.header p {
font-size: 1.1em;
opacity: 0.9;
margin-bottom: 15px;
}
.nav {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.nav-btn {
padding: 8px 16px;
background: #f8f9fa;
border: 1px solid #ddd;
border-radius: 5px;
text-decoration: none;
color: #333;
transition: all 0.3s;
}
.nav-btn:hover {
background: #667eea;
color: white;
border-color: #667eea;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.08);
text-align: center;
transition: transform 0.3s ease;
}
.stat-card:hover {
transform: translateY(-5px);
}
.stat-number {
font-size: 2.5em;
font-weight: bold;
color: #667eea;
margin-bottom: 5px;
}
.stat-label {
color: #666;
font-size: 0.9em;
}
.view-switcher {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.view-btn {
padding: 10px 20px;
border: 2px solid #667eea;
background: white;
color: #667eea;
border-radius: 25px;
cursor: pointer;
transition: all 0.3s ease;
font-weight: bold;
}
.view-btn.active {
background: #667eea;
color: white;
}
.view-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
}
.chat-container {
display: grid;
grid-template-columns: 300px 1fr;
gap: 20px;
height: 70vh;
}
.sidebar {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.08);
overflow-y: auto;
}
.sidebar h3 {
margin-bottom: 15px;
color: #333;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
}
.group-list {
list-style: none;
margin-bottom: 20px;
}
.group-item {
padding: 15px;
margin-bottom: 10px;
background: #f8f9fa;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
border-left: 4px solid transparent;
}
.group-item:hover {
background: #e9ecef;
transform: translateX(5px);
}
.group-item.active {
background: #667eea;
color: white;
border-left-color: #764ba2;
}
.group-name {
font-weight: bold;
margin-bottom: 5px;
}
.group-info {
font-size: 0.8em;
color: #666;
display: flex;
justify-content: space-between;
}
.group-item.active .group-info {
color: rgba(255, 255, 255, 0.8);
}
.chat-area {
background: white;
border-radius: 12px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
}
.chat-header {
padding: 20px;
border-bottom: 1px solid #eee;
background: #f8f9fa;
border-radius: 12px 12px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.current-group {
font-size: 1.2em;
font-weight: bold;
color: #333;
}
.group-stats {
font-size: 0.9em;
color: #666;
}
.controls {
padding: 20px;
border-bottom: 1px solid #eee;
background: #f8f9fa;
border-radius: 0 0 0 0;
text-align: center;
}
.load-more {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 12px 24px;
border-radius: 25px;
cursor: pointer;
font-size: 1em;
transition: transform 0.3s ease;
}
.load-more:hover {
transform: scale(1.05);
}
.chat-messages {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 20px;
}
.message {
margin-bottom: 20px;
display: flex;
align-items: flex-start;
}
.message.own {
flex-direction: row-reverse;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
margin: 0 10px;
}
.message-content {
max-width: 70%;
padding: 12px 16px;
border-radius: 18px;
background: #f1f3f5;
position: relative;
word-wrap: break-word;
overflow-wrap: break-word;
white-space: normal;
}
.message.own .message-content {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.message-sender {
font-weight: bold;
margin-bottom: 5px;
font-size: 0.9em;
}
.message-time {
font-size: 0.8em;
color: #999;
margin-top: 5px;
}
.message.own .message-time {
color: rgba(255, 255, 255, 0.8);
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
.error {
background: #ffe6e6;
color: #d63031;
padding: 10px;
border-radius: 8px;
margin: 10px 0;
text-align: center;
}
@media (max-width: 768px) {
.chat-container {
grid-template-columns: 1fr;
}
.sidebar {
display: none;
}
.message-content {
max-width: 85%;
}
}
body, .container {
overflow-x: hidden;
}
/* 聊天记录按钮样式 */
.chatrecord-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 8px 16px;
border-radius: 15px;
cursor: pointer;
font-size: 0.9em;
transition: all 0.3s ease;
text-align: left;
max-width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
white-space: normal;
}
.chatrecord-btn:hover {
transform: scale(1.05);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
}
/* 模态框样式 */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
animation: fadeIn 0.3s;
}
.modal-content {
background-color: white;
margin: 5% auto;
padding: 0;
border-radius: 15px;
width: 80%;
max-width: 800px;
max-height: 80vh;
overflow: hidden;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
animation: slideIn 0.3s;
}
.modal-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 1.3em;
}
.close {
color: white;
font-size: 28px;
font-weight: bold;
cursor: pointer;
transition: opacity 0.3s;
}
.close:hover {
opacity: 0.7;
}
.modal-body {
padding: 0;
max-height: calc(80vh - 80px);
overflow-y: auto;
}
.chat-record-content {
padding: 20px;
}
.chat-record-item {
margin-bottom: 15px;
padding: 15px;
background: #f8f9fa;
border-radius: 10px;
border-left: 4px solid #667eea;
}
.chat-record-sender {
font-weight: bold;
margin-bottom: 5px;
color: #333;
}
.chat-record-text {
margin-bottom: 5px;
line-height: 1.4;
}
.chat-record-time {
font-size: 0.8em;
color: #666;
text-align: right;
}
.chat-record-image {
max-width: 200px;
max-height: 200px;
border-radius: 8px;
margin: 5px 0;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideIn {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.container {
max-width: 100%;
overflow-x: hidden;
}

274
static/css/markdown.css Normal file
View File

@@ -0,0 +1,274 @@
:root {
--primary-color: #2c3e50;
--secondary-color: #3498db;
--accent-color: #e74c3c;
--light-bg: #f8f9fa;
--dark-bg: #2c3e50;
--text-color: #333;
--border-color: #e0e0e0;
--shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
* {
margin: 5px;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 1400px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.header {
text-align: center;
margin-bottom: 30px;
color: white;
}
.header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
.header p {
font-size: 1.1rem;
opacity: 0.9;
}
.main-content {
background: white;
border-radius: 15px;
padding: 30px;
box-shadow: var(--shadow);
margin-bottom: 30px;
}
.container {
display: flex;
gap: 30px;
margin-bottom: 40px;
}
.editor, .preview {
flex: 1;
border: 2px solid var(--border-color);
padding: 25px;
border-radius: 10px;
background: white;
transition: all 0.3s ease;
}
.editor:hover, .preview:hover {
border-color: var(--secondary-color);
box-shadow: 0 4px 15px rgba(52, 152, 219, 0.2);
}
.editor h3, .preview h3 {
color: var(--primary-color);
margin-bottom: 15px;
font-size: 1.3rem;
border-bottom: 2px solid var(--secondary-color);
padding-bottom: 10px;
}
textarea {
width: 100%;
height: 400px;
font-family: 'Fira Code', 'Courier New', monospace;
font-size: 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 15px;
resize: vertical;
transition: all 0.3s ease;
background: #fafafa;
}
textarea:focus {
outline: none;
border-color: var(--secondary-color);
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
background: white;
}
.toolbar {
margin-top: 20px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button {
padding: 12px 24px;
background: linear-gradient(135deg, var(--secondary-color), #2980b9);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(52, 152, 219, 0.3);
}
button:active {
transform: translateY(0);
}
#htmlPreview {
min-height: 400px;
padding: 15px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: #fafafa;
overflow-y: auto;
}
#htmlPreview::-webkit-scrollbar {
display: none;
}
.syntax-guide {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.guide-card {
background: var(--light-bg);
padding: 20px;
border-radius: 10px;
border-left: 4px solid var(--secondary-color);
}
.guide-card h3 {
color: var(--primary-color);
margin-bottom: 15px;
font-size: 1.2rem;
}
.syntax {
background: #2c3e50;
color: white;
padding: 8px 12px;
border-radius: 5px;
font-family: 'Fira Code', monospace;
font-size: 13px;
margin: 5px 0;
display: inline-block;
}
@keyframes slideIn {
from {
transform: translateY(-10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Blockquote 样式 - 注释样式,字体变小,颜色变灰 */
blockquote {
background: linear-gradient(135deg, #f8f9fa, #e9ecef);
border-left: 4px solid #6c757d;
margin: 20px 0;
padding: 15px 20px;
font-size: 0.9em;
color: #6c757d;
font-style: italic;
border-radius: 0 8px 8px 0;
position: relative;
}
blockquote::before {
content: "❝";
font-size: 2em;
color: #adb5bd;
position: absolute;
left: 10px;
top: 5px;
opacity: 0.5;
}
blockquote p {
margin: 0;
line-height: 1.5;
}
/* CodeBlock 样式 - 更鲜艳的样式 */
pre {
background: linear-gradient(135deg, #2d3748, #4a5568);
border: 1px solid #4a5568;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
overflow-x: auto;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
position: relative;
white-space: pre;
font-family: 'Fira Code', monospace;
font-size: 13px;
line-height: 1.4;
color: #e2e8f0;
}
pre::before {
content: "</>";
position: absolute;
top: 10px;
right: 15px;
color: #81e6d9;
font-size: 0.8em;
font-weight: bold;
opacity: 0.7;
}
/* 行内代码样式 */
:not(pre) > code {
background: linear-gradient(135deg, #fed7e2, #fbb6ce);
color: #702459;
padding: 2px 6px;
border-radius: 4px;
line-height: 1.4;
border: 1px solid #fbb6ce;
}
.footer {
text-align: center;
color: white;
margin-top: 30px;
opacity: 0.8;
}
@media (max-width: 768px) {
.container {
flex-direction: column;
}
.syntax-guide {
grid-template-columns: 1fr;
}
.header h1 {
font-size: 2rem;
}
}

848
static/css/qa.css Normal file
View File

@@ -0,0 +1,848 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px 0;
text-align: center;
border-radius: 10px;
margin-bottom: 30px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.header p {
font-size: 1.1em;
opacity: 0.9;
}
.controls {
background: white;
padding: 20px;
border-radius: 10px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
/* 过滤区域样式 */
.filter-section {
margin-bottom: 20px;
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
border: 1px solid #e9ecef;
}
.filter-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #dee2e6;
}
.filter-header h3 {
font-size: 18px;
color: #495057;
margin: 0;
font-weight: 600;
}
.filter-actions {
display: flex;
gap: 10px;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
}
.filter-item {
display: flex;
flex-direction: column;
}
.filter-item label {
font-weight: 600;
margin-bottom: 8px;
font-size: 14px;
color: #495057;
}
.filter-item input,
.filter-item select {
padding: 10px 12px;
border: 1px solid #ced4da;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.filter-item input:focus,
.filter-item select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.action-section {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
background: white;
border-radius: 8px;
border: 1px solid #e9ecef;
margin-top: 20px;
}
.stats-section {
flex: 1;
}
.stats-info {
font-size: 15px;
color: #6c757d;
font-weight: 600;
padding: 8px 0;
}
.create-section {
display: flex;
gap: 10px;
}
/* 批量操作区域样式 */
.batch-actions {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 5px;
padding: 15px;
margin-bottom: 20px;
}
.batch-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.batch-select {
display: flex;
align-items: center;
gap: 10px;
}
.batch-select input[type="checkbox"] {
width: 18px;
height: 18px;
}
.batch-select label {
font-weight: bold;
color: #495057;
margin: 0;
}
.batch-info {
font-size: 14px;
color: #6c757d;
font-weight: bold;
}
.batch-buttons {
display: flex;
gap: 10px;
}
/* QA项目选中状态 */
.qa-item.selected {
background-color: #e3f2fd;
border-color: #2196f3;
}
.qa-checkbox {
margin-right: 10px;
}
.qa-checkbox input[type="checkbox"] {
width: 16px;
height: 16px;
}
.search-box {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.search-box input {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s ease;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5a6fd8;
transform: translateY(-2px);
}
.btn-success {
background: #28a745;
color: white;
}
.btn-success:hover {
background: #218838;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.btn-outline {
background: transparent;
color: #6c757d;
border: 1px solid #6c757d;
}
.btn-outline:hover {
background: #6c757d;
color: white;
}
.qa-list {
background: white;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.qa-item {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
margin-bottom: 15px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
display: flex;
flex-direction: column;
}
.qa-item:hover {
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.qa-item.selected {
background-color: #e3f2fd;
border-color: #2196f3;
box-shadow: 0 4px 15px rgba(33, 150, 243, 0.2);
}
.qa-header {
display: flex;
align-items: center;
margin-bottom: 15px;
gap: 15px;
}
.qa-checkbox {
flex-shrink: 0;
}
.qa-id {
font-weight: bold;
color: #667eea;
font-size: 16px;
}
.qa-group {
background: #f0f0f0;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
color: #666;
}
.qa-id {
font-weight: bold;
color: #667eea;
font-size: 1.2em;
}
.qa-group {
background: #e9ecef;
padding: 5px 10px;
border-radius: 15px;
font-size: 0.9em;
}
.qa-content {
margin-bottom: 15px;
}
.nav {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.nav-btn {
padding: 8px 16px;
background: #f8f9fa;
border: 1px solid #ddd;
border-radius: 5px;
text-decoration: none;
color: #333;
transition: all 0.3s;
}
.nav-btn:hover {
background: #667eea;
color: white;
border-color: #667eea;
}
.qa-section {
margin-bottom: 10px;
}
.qa-section h4 {
color: #495057;
margin-bottom: 5px;
font-size: 1em;
}
.message-list {
background: #f8f9fa;
padding: 10px;
border-radius: 5px;
max-height: 150px;
overflow-y: auto;
}
.message-item {
padding: 5px;
margin-bottom: 5px;
background: white;
border-radius: 3px;
font-size: 0.9em;
border-left: 3px solid #667eea;
}
.qa-actions {
display: flex;
gap: 10px;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
background-color: white;
margin: 5% auto;
padding: 30px;
border-radius: 10px;
width: 80%;
max-width: 800px;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.close {
font-size: 28px;
font-weight: bold;
cursor: pointer;
color: #aaa;
}
.close:hover {
color: #000;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
}
.message-selector {
border: 1px solid #ddd;
border-radius: 5px;
padding: 15px;
margin-bottom: 15px;
}
.search-results {
max-height: 200px;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 5px;
margin-top: 10px;
}
.search-result-item {
padding: 10px;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background-color 0.2s;
}
.search-result-item:hover {
background-color: #f0f0f0;
}
.selected-messages {
background: #f8f9fa;
padding: 10px;
border-radius: 5px;
margin-top: 10px;
}
.selected-message {
display: flex;
justify-content: space-between;
align-items: center;
padding: 5px;
margin-bottom: 5px;
background: white;
border-radius: 3px;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
.empty-state {
text-align: center;
padding: 40px;
color: #666;
}
.empty-state i {
font-size: 48px;
margin-bottom: 10px;
opacity: 0.5;
}
/* 搜索相关样式 */
.search-container {
position: relative;
}
.search-input-wrapper {
position: relative;
}
.search-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 5px 5px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
z-index: 1000;
display: none;
}
.search-suggestion-item {
padding: 10px 15px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s;
}
.search-suggestion-item:hover {
background-color: #f8f9fa;
}
.search-suggestion-item:last-child {
border-bottom: none;
}
.search-history {
margin-top: 10px;
border: 1px solid #ddd;
border-radius: 5px;
background: white;
display: none;
}
.search-history-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
border-bottom: 1px solid #eee;
background: #f8f9fa;
}
.search-history-header span {
font-weight: bold;
color: #495057;
}
.btn-clear-history {
background: none;
border: none;
color: #6c757d;
cursor: pointer;
font-size: 12px;
text-decoration: underline;
}
.btn-clear-history:hover {
color: #495057;
}
.search-history-items {
max-height: 200px;
overflow-y: auto;
}
.search-history-item {
padding: 8px 15px;
cursor: pointer;
border-bottom: 1px solid #f8f9fa;
transition: background-color 0.2s;
}
.search-history-item:hover {
background-color: #f8f9fa;
}
.search-history-item:last-child {
border-bottom: none;
}
/* 按钮样式增强 */
.btn-secondary {
background-color: #6c757d;
border-color: #6c757d;
}
.btn-secondary:hover {
background-color: #5a6268;
border-color: #545b62;
}
.btn-info {
background-color: #17a2b8;
border-color: #17a2b8;
}
.btn-info:hover {
background-color: #138496;
border-color: #117a8b;
}
/* 搜索结果样式 */
.search-results-header {
background: #f8f9fa;
padding: 15px 20px;
border-radius: 5px;
margin-bottom: 15px;
border-left: 4px solid #667eea;
}
.search-results-header h3 {
margin: 0 0 5px 0;
color: #495057;
}
.search-summary {
color: #6c757d;
font-size: 0.9em;
}
.search-result-item {
border-left: 3px solid #28a745;
background: #f8fff9;
}
.message-content {
background: white;
padding: 10px;
border-radius: 3px;
border: 1px solid #e9ecef;
margin-top: 5px;
line-height: 1.5;
}
.message-content mark {
background-color: #fff3cd;
padding: 2px 4px;
border-radius: 2px;
font-weight: bold;
}
/* 聊天记录预览样式 */
.chat-preview {
background: white;
border-radius: 10px;
overflow: hidden;
}
.chat-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
text-align: center;
}
.chat-header h3 {
margin: 0 0 10px 0;
font-size: 1.5em;
}
.chat-info {
opacity: 0.9;
font-size: 0.9em;
}
.chat-container {
padding: 20px;
max-height: 400px;
overflow-y: auto;
background: #f8f9fa;
}
.chat-message {
display: flex;
margin-bottom: 15px;
animation: fadeIn 0.3s ease;
}
.chat-message.question {
flex-direction: row;
}
.chat-message.answer {
flex-direction: row-reverse;
}
.chat-message.other {
flex-direction: row;
opacity: 0.7;
}
.message-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
margin: 0 10px;
flex-shrink: 0;
}
.chat-message.question .message-avatar {
background: #e3f2fd;
color: #1976d2;
}
.chat-message.answer .message-avatar {
background: #f3e5f5;
color: #7b1fa2;
}
.chat-message.other .message-avatar {
background: #f5f5f5;
color: #757575;
}
.message-content {
flex: 1;
max-width: 70%;
}
.message-header {
display: flex;
align-items: center;
margin-bottom: 5px;
gap: 10px;
}
.sender {
font-weight: bold;
color: #333;
}
.time {
font-size: 0.8em;
color: #666;
}
.message-type-badge {
padding: 2px 8px;
border-radius: 12px;
font-size: 0.7em;
font-weight: bold;
}
.message-type-badge.question {
background: #e3f2fd;
color: #1976d2;
}
.message-type-badge.answer {
background: #f3e5f5;
color: #7b1fa2;
}
.message-type-badge.other {
background: #f5f5f5;
color: #757575;
}
.message-text {
background: white;
padding: 12px 15px;
border-radius: 18px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
line-height: 1.4;
word-wrap: break-word;
}
.chat-message.question .message-text {
background: #e3f2fd;
border-bottom-left-radius: 5px;
}
.chat-message.answer .message-text {
background: #f3e5f5;
border-bottom-right-radius: 5px;
}
.chat-message.other .message-text {
background: #f5f5f5;
border-radius: 18px;
}
.message-meta {
display: flex;
justify-content: space-between;
margin-top: 5px;
font-size: 0.7em;
color: #999;
}
.chat-summary {
background: white;
padding: 15px 20px;
border-top: 1px solid #eee;
display: flex;
justify-content: space-around;
}
.summary-item {
text-align: center;
}
.summary-label {
display: block;
font-size: 0.8em;
color: #666;
margin-bottom: 5px;
}
.summary-value {
display: block;
font-size: 1.2em;
font-weight: bold;
color: #667eea;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,534 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 10px;
margin-bottom: 30px;
text-align: center;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.header p {
font-size: 1.2em;
opacity: 0.9;
}
.controls {
background: white;
padding: 20px;
border-radius: 10px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.control-group {
display: flex;
gap: 20px;
align-items: center;
flex-wrap: wrap;
}
.control-group label {
font-weight: bold;
color: #555;
}
.control-group input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
}
.btn {
background: #667eea;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.btn:hover {
background: #5a6fd8;
}
.btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.report-container {
background: white;
border-radius: 10px;
padding: 30px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.report-content {
white-space: pre-wrap;
font-family: 'Courier New', monospace;
line-height: 1.8;
font-size: 14px;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.error {
background: #ffe6e6;
color: #d63031;
padding: 15px;
border-radius: 5px;
margin: 10px 0;
border-left: 4px solid #d63031;
}
.statistics {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-top: 20px;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.stat-item {
background: white;
padding: 15px;
border-radius: 8px;
text-align: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.stat-value {
font-size: 1.8em;
font-weight: bold;
color: #667eea;
}
.stat-label {
font-size: 0.9em;
color: #666;
margin-top: 5px;
}
.category-chart {
margin: 20px 0;
}
.category-item {
display: flex;
align-items: center;
margin: 10px 0;
padding: 10px;
background: #f8f9fa;
border-radius: 5px;
}
.category-bar {
height: 20px;
background: linear-gradient(90deg, #667eea, #764ba2);
border-radius: 10px;
margin: 0 10px;
min-width: 50px;
}
.nav {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.nav-btn {
padding: 8px 16px;
background: #f8f9fa;
border: 1px solid #ddd;
border-radius: 5px;
text-decoration: none;
color: #333;
transition: all 0.3s;
}
.nav-btn:hover {
background: #667eea;
color: white;
border-color: #667eea;
}
.nav-btn.active {
background: #667eea;
color: white;
border-color: #667eea;
}
.api-version {
background-color: #e7f3ff;
border-left: 4px solid #1890ff;
padding: 10px 15px;
margin: 10px 0;
border-radius: 4px;
color: #1890ff;
font-size: 14px;
line-height: 1.5;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.api-version strong {
color: #096dd9;
font-weight: 600;
}
.api-version:hover {
background-color: #d4e6ff;
transition: background-color 0.3s ease;
}
/* 表格样式 - 控制长文本换行 */
.report-content table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
font-size: 14px;
table-layout: fixed;
}
.report-content th,
.report-content td {
border: 1px solid #ddd;
padding: 8px 12px;
text-align: left;
word-wrap: break-word;
word-break: break-all;
overflow-wrap: break-word;
hyphens: auto;
}
.report-content th {
background-color: #f8f9fa;
font-weight: bold;
color: #333;
}
.report-content tr:nth-child(even) {
background-color: #f8f9fa;
}
.report-content tr:hover {
background-color: #e9ecef;
}
/* 针对特定列设置不同的宽度 */
.report-content td:nth-child(1) { /* 第一列 - 客户群名称 */
width: 20%;
min-width: 120px;
}
.report-content td:nth-child(2), /* 第二列 - 消息总量 */
.report-content td:nth-child(3) { /* 第三列 - 活跃人数 */
width: 10%;
min-width: 80px;
text-align: center;
}
.report-content td:nth-child(4) { /* 第四列 - 核心互动时段 */
width: 20%;
min-width: 150px;
}
.report-content td:nth-child(5) { /* 第五列 - 平均每日消息量 */
width: 15%;
min-width: 100px;
text-align: center;
}
/* 问题分类表格样式 */
.report-content td:nth-child(1) { /* 问题类别 */
width: 15%;
min-width: 100px;
}
.report-content td:nth-child(2) { /* 具体问题描述 */
width: 40%;
min-width: 200px;
}
.report-content td:nth-child(3), /* 出现频次 */
.report-content td:nth-child(4), /* 涉及群聊 */
.report-content td:nth-child(5) { /* 当前状态 */
width: 15%;
min-width: 80px;
text-align: center;
}
/* 响应式表格 */
@media (max-width: 768px) {
.report-content table {
font-size: 12px;
}
.report-content th,
.report-content td {
padding: 6px 8px;
}
.report-content td:nth-child(n) {
width: auto;
min-width: 60px;
}
}
.export-btn{
background: #28a745;
color: white;
border: none;
padding: 8px 16px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
margin-left: auto;
}
.export-btn:hover {
background: #218838;
}
/* 图片导出按钮样式 */
.image-export-btn {
background: #17a2b8;
color: white;
border: none;
padding: 8px 16px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
margin-left: 10px;
transition: all 0.3s ease;
}
.image-export-btn:hover:not(.disabled) {
background: #138496;
transform: translateY(-1px);
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}
.image-export-btn.disabled {
background: #6c757d;
cursor: not-allowed;
opacity: 0.6;
}
.image-export-btn.disabled:hover {
background: #6c757d;
transform: none;
box-shadow: none;
}
/* 预览控制样式 */
.preview-controls {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 1px solid #eee;
padding-bottom: 15px;
}
.preview-btn {
background: #f8f9fa;
color: #666;
border: 1px solid #ddd;
padding: 8px 16px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.preview-btn:hover {
background: #e9ecef;
border-color: #adb5bd;
}
.preview-btn.active {
background: #667eea;
color: white;
border-color: #667eea;
}
/* Markdown源代码显示样式 */
.markdown-source {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 5px;
padding: 20px;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
max-height: 600px;
overflow-y: auto;
}
.markdown-source pre {
margin: 0;
background: none;
border: none;
padding: 0;
}
.markdown-source code {
background: none;
border: none;
padding: 0;
color: #333;
}
/* 增强的报告内容样式 */
.report-content {
white-space: pre-wrap;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.8;
font-size: 14px;
background: white;
padding: 20px;
border-radius: 5px;
border: 1px solid #e9ecef;
}
/* Markdown渲染样式 */
.report-content h1,
.report-content h2,
.report-content h3,
.report-content h4,
.report-content h5,
.report-content h6 {
margin: 20px 0 10px 0;
color: #333;
font-weight: 600;
}
.report-content h1 {
font-size: 1.8em;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
}
.report-content h2 {
font-size: 1.5em;
border-bottom: 1px solid #ddd;
padding-bottom: 8px;
}
.report-content h3 {
font-size: 1.3em;
}
.report-content p {
margin: 10px 0;
}
.report-content ul,
.report-content ol {
margin: 10px 0 10px 20px;
}
.report-content li {
margin: 5px 0;
}
.report-content blockquote {
border-left: 4px solid #667eea;
background: #f8f9fa;
padding: 10px 15px;
margin: 15px 0;
font-style: italic;
}
.report-content code {
background: #f1f3f4;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.report-content pre {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 5px;
padding: 15px;
overflow-x: auto;
margin: 15px 0;
}
.report-content pre code {
background: none;
padding: 0;
}
.report-content table {
border-collapse: collapse;
width: 100%;
margin: 15px 0;
}
.report-content th,
.report-content td {
border: 1px solid #ddd;
padding: 8px 12px;
text-align: left;
}
.report-content th {
background: #f8f9fa;
font-weight: 600;
}
.report-content tr:nth-child(even) {
background: #f8f9fa;
}
/* 水平分隔线样式 */
.report-content hr {
border: none;
border-top: 2px solid #667eea;
margin: 30px 0;
height: 0;
}

148
static/js/daily_report.js Normal file
View File

@@ -0,0 +1,148 @@
togglePreview = function (mode) {
const previewBtn = document.getElementById('preview-toggle');
const sourceBtn = document.getElementById('source-toggle');
const reportContent = document.getElementById('report-content');
const markdownSource = document.getElementById('markdown-source');
const sourceContent = document.getElementById('source-content');
if (mode === 'preview') {
previewBtn.classList.add('active');
sourceBtn.classList.remove('active');
reportContent.style.display = 'block';
markdownSource.style.display = 'none';
} else {
previewBtn.classList.remove('active');
sourceBtn.classList.add('active');
reportContent.style.display = 'none';
markdownSource.style.display = 'block';
// 显示原始Markdown源代码
const markdown = reportContent.getAttribute('data-markdown');
sourceContent.textContent = markdown;
}
};
// 设置默认日期为今天
document.getElementById('date').value = new Date().toISOString().split('T')[0];
function showLoading() {
document.getElementById('loading').style.display = 'block';
document.getElementById('report-container').style.display = 'none';
document.getElementById('error').style.display = 'none';
}
function hideLoading() {
document.getElementById('loading').style.display = 'none';
}
function showError(message) {
document.getElementById('error').style.display = 'block';
document.getElementById('error').textContent = message;
}
function generateTodayReport() {
document.getElementById('date').value = new Date().toISOString().split('T')[0];
generateReport();
}
function generateReport() {
const date = document.getElementById('date').value;
if (!date) {
showError('请选择日期');
return;
}
showLoading();
fetch(`api/reports/daily?date=${date}`)
.then(response => response.json())
.then(data => {
hideLoading();
if (data.success) {
// 保存原始Markdown内容到data属性
document.getElementById('report-content').setAttribute('data-markdown', data.report);
// 使用专业的Markdown解析器将Markdown转换为HTML并显示
const htmlContent = parseMarkdownWithTables(data.report);
document.getElementById('report-content').innerHTML = htmlContent;
// 重置为预览模式
togglePreview('preview');
document.getElementById('report-container').style.display = 'block';
// 显示统计数据
if (data.statistics) {
displayStatistics(data.statistics);
document.getElementById('statistics').style.display = 'block';
}
} else {
showError('生成日报失败:' + data.error);
}
})
.catch(error => {
hideLoading();
showError('网络错误:' + error.message);
});
}
function displayStatistics(stats) {
const statGrid = document.getElementById('stat-grid');
statGrid.innerHTML = '';
const statistics = [
{label: '今日消息量', value: stats.today_message_count, unit: '条'},
{label: '昨日消息量', value: stats.yesterday_message_count, unit: '条'},
{label: '消息变化率', value: stats.message_change_rate.toFixed(1), unit: '%'},
{label: '今日活跃用户', value: stats.today_active_users, unit: '人'},
{label: '昨日活跃用户', value: stats.yesterday_active_users, unit: '人'},
{label: '用户变化率', value: stats.user_change_rate.toFixed(1), unit: '%'},
{label: '群组数量', value: stats.group_count, unit: '个'},
{label: '成员总数', value: stats.member_count, unit: '人'}
];
statistics.forEach(stat => {
const statItem = document.createElement('div');
statItem.className = 'stat-item';
statItem.innerHTML = `
<div class="stat-value">${stat.value}${stat.unit}</div>
<div class="stat-label">${stat.label}</div>
`;
statGrid.appendChild(statItem);
});
}
function exportReport() {
const reportContent = document.getElementById('report-content').getAttribute('data-markdown');
if (!reportContent) {
showError('请先生成日报');
return;
}
const blob = new Blob([reportContent], {type: 'text/markdown'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `运营日报_${document.getElementById('date').value}.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// 页面加载时自动生成今日日报
window.addEventListener('load', function () {
generateTodayReport();
});
// 使用专业的Markdown解析器解析Markdown文本,支持表格
function parseMarkdownWithTables(markdown) {
if (typeof MarkdownParser !== 'undefined' && typeof MergeTableParser !== 'undefined') {
const parser = new MarkdownParser();
return parser.parseFull(markdown);
} else {
// 回退到简单的markdown渲染
return markdownToHtml(markdown);
}
}

182
static/js/h2p.js Normal file
View File

@@ -0,0 +1,182 @@
/**
* HTML转图片工具
* 支持将HTML元素转换为PNG、JPEG格式的图片
* 使用html2canvas库实现
*/
class HTMLToImage {
constructor(options = {}) {
this.options = {
scale: 2, // 缩放比例,提高图片清晰度
useCORS: true, // 启用跨域图片支持
allowTaint: false, // 不允许污染画布
backgroundColor: '#ffffff', // 背景颜色
width: null, // 自定义宽度
height: null, // 自定义高度
...options
};
// 检查是否已加载html2canvas
if (typeof html2canvas === 'undefined') {
console.warn('html2canvas未加载,将动态加载...');
this.loadHtml2Canvas();
}
}
/**
* 将HTML元素转换为图片
* @param {HTMLElement} element - 要转换的HTML元素
* @param {Object} options - 转换选项
* @returns {Promise<string>} - 返回图片的DataURL
*/
async toImage(element, options = {}) {
try {
// 确保html2canvas已加载
if (typeof html2canvas === 'undefined') {
await this.loadHtml2Canvas();
}
const mergedOptions = { ...this.options, ...options };
const canvas = await html2canvas(element, mergedOptions);
return canvas.toDataURL('image/png');
} catch (error) {
console.error('HTML转图片失败:', error);
throw error;
}
}
/**
* 将HTML元素转换为JPEG图片
* @param {HTMLElement} element - 要转换的HTML元素
* @param {number} quality - JPEG质量 (0-1)
* @param {Object} options - 转换选项
* @returns {Promise<string>} - 返回图片的DataURL
*/
async toJPEG(element, quality = 0.92, options = {}) {
try {
if (typeof html2canvas === 'undefined') {
await this.loadHtml2Canvas();
}
const mergedOptions = { ...this.options, ...options };
const canvas = await html2canvas(element, mergedOptions);
return canvas.toDataURL('image/jpeg', quality);
} catch (error) {
console.error('HTML转JPEG失败:', error);
throw error;
}
}
/**
* 下载图片
* @param {string} dataURL - 图片的DataURL
* @param {string} filename - 文件名
*/
downloadImage(dataURL, filename = 'image.png') {
const link = document.createElement('a');
link.download = filename;
link.href = dataURL;
link.click();
}
/**
* 将图片DataURL转换为Blob对象
* @param {string} dataURL - 图片的DataURL
* @returns {Promise<Blob>}
*/
dataURLToBlob(dataURL) {
return new Promise((resolve) => {
const byteString = atob(dataURL.split(',')[1]);
const mimeString = dataURL.split(',')[0].split(':')[1].split(';')[0];
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
resolve(new Blob([ab], { type: mimeString }));
});
}
/**
* 获取图片的尺寸信息
* @param {string} dataURL - 图片的DataURL
* @returns {Promise<{width: number, height: number}>}
*/
getImageDimensions(dataURL) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
resolve({
width: img.width,
height: img.height
});
};
img.src = dataURL;
});
}
/**
* 批量转换多个元素
* @param {HTMLElement[]} elements - 要转换的元素数组
* @param {Object} options - 转换选项
* @returns {Promise<string[]>} - 返回图片DataURL数组
*/
async batchConvert(elements, options = {}) {
const results = [];
for (let i = 0; i < elements.length; i++) {
try {
const dataURL = await this.toImage(elements[i], options);
results.push(dataURL);
console.log(`第 ${i + 1} 个元素转换完成`);
} catch (error) {
console.error(`第 ${i + 1} 个元素转换失败:`, error);
results.push(null);
}
}
return results;
}
}
/**
* 便捷函数:快速转换并下载
* @param {HTMLElement} element - 要转换的HTML元素
* @param {string} filename - 文件名
* @param {Object} options - 转换选项
*/
async function convertAndDownload(element, filename = 'screenshot.png', options = {}) {
const converter = new HTMLToImage(options);
try {
const dataURL = await converter.toImage(element, options);
converter.downloadImage(dataURL, filename);
console.log('图片下载成功');
return Promise.resolve(); // 返回成功的Promise
} catch (error) {
console.error('转换下载失败:', error);
return Promise.reject(error); // 返回失败的Promise
}
}
/**
* 便捷函数:获取图片DataURL
* @param {HTMLElement} element - 要转换的HTML元素
* @param {Object} options - 转换选项
* @returns {Promise<string>}
*/
async function getImageDataURL(element, options = {}) {
const converter = new HTMLToImage(options);
return await converter.toImage(element, options);
}
// 导出到全局作用域
if (typeof window !== 'undefined') {
window.HTMLToImage = HTMLToImage;
window.convertAndDownload = convertAndDownload;
window.getImageDataURL = getImageDataURL;
}

20
static/js/html2canvas.min.js vendored Normal file

File diff suppressed because one or more lines are too long

303
static/js/index.js Normal file
View File

@@ -0,0 +1,303 @@
let currentOffset = 0;
const limit = 50;
let currentView = 'groups';
let currentGroupId = null;
// 页面加载时初始化
document.addEventListener('DOMContentLoaded', function () {
loadStatistics();
loadGroupMessages();
loadMessages();
});
// 加载统计信息
async function loadStatistics() {
try {
const response = await fetch('api/statistics');
const data = await response.json();
if (data) {
const stats = data;
document.getElementById('userCount').textContent = stats.user_count;
document.getElementById('groupCount').textContent = stats.group_count;
document.getElementById('messageCount').textContent = stats.message_count;
document.getElementById('fileCount').textContent = stats.file_count;
}
} catch (error) {
console.error('加载统计信息失败:', error);
}
}
// 加载按群组分组的消息统计
async function loadGroupMessages() {
try {
const response = await fetch('api/groups');
const data = await response.json();
const groupList = document.getElementById('groupList');
if (data.success) {
const groups = data.groups || [];
groupList.innerHTML = '';
if (groups.length === 0) {
groupList.innerHTML = '<li>暂无群组</li>';
return;
} else {
currentGroupId = groups[0].id;
}
groups.forEach(group => {
const li = document.createElement('li');
li.className = 'group-item';
li.setAttribute('data-group-id', group.id);
li.onclick = () => selectGroup(group.id);
const groupName = document.createElement('div');
groupName.className = 'group-name';
groupName.textContent = group.name || group.id;
const groupInfo = document.createElement('div');
groupInfo.className = 'group-info';
groupInfo.innerHTML = `
<span>创建者: ${group.owner || '未知'}</span>
`;
li.appendChild(groupName);
li.appendChild(groupInfo);
groupList.appendChild(li);
});
// 默认选择第一个群组
if (currentView === 'groups' && groups.length > 0) {
selectGroup(groups[0].id);
}
} else {
groupList.innerHTML = '<li>加载失败</li>';
}
} catch (error) {
console.error('加载群组消息失败:', error);
}
}
// 选择群组
function selectGroup(groupId) {
currentGroupId = groupId;
currentOffset = 0;
// 更新选中状态
document.querySelectorAll('.group-item').forEach(item => {
item.classList.remove('active');
});
// 找到对应的群组项并添加选中状态
document.querySelectorAll('.group-item').forEach(item => {
// 使用data-group-id属性来准确匹配群组
if (item.getAttribute('data-group-id') === groupId) {
item.classList.add('active');
// 更新群组信息
document.getElementById('currentGroup').textContent = item.querySelector('.group-name').textContent;
document.getElementById('messageCountDisplay').textContent = '消息数量: 加载中...';
document.getElementById('latestTime').textContent = '最新消息: 加载中...';
}
});
// 加载该群组的消息
loadMessages();
// 加载统计信息
loadStatistics();
}
// 加载更多消息
async function loadMoreMessages() {
await loadMessages();
}
// 关闭聊天记录模态框
function closeChatRecordModal() {
document.getElementById('chatRecordModal').style.display = 'none';
}
// 显示聊天记录模态框
function showChatRecordModal(chatrecordData) {
const modal = document.getElementById('chatRecordModal');
const title = document.getElementById('modalTitle');
const content = document.getElementById('chatRecordContent');
// 设置模态框标题
if (chatrecordData.chatrecord && chatrecordData.chatrecord.title) {
title.textContent = chatrecordData.chatrecord.title;
} else {
title.textContent = '聊天记录';
}
// 渲染聊天记录内容
renderChatRecordContent(chatrecordData, content);
modal.style.display = 'block';
}
// 渲染聊天记录内容
function renderChatRecordContent(chatrecordData, contentElement) {
try {
if (!chatrecordData.chatrecord || !chatrecordData.chatrecord.item) {
contentElement.innerHTML = '<div class="error">聊天记录数据格式错误</div>';
return;
}
const chatItems = chatrecordData.chatrecord.item;
if (!Array.isArray(chatItems) || chatItems.length === 0) {
contentElement.innerHTML = '<div class="error">暂无聊天记录内容</div>';
return;
}
let html = '';
chatItems.forEach(item => {
// 处理不同类型的聊天记录项
let contentHtml = '';
if (item.type === 'ChatRecordText') {
// 文本消息
try {
const contentData = JSON.parse(item.content);
contentHtml = contentData.content || item.content || '';
} catch (e) {
contentHtml = item.content || '';
}
} else if (item.type === 'ChatRecordImage') {
// 图片消息
contentHtml = '<div class="chat-record-image">[图片消息]</div>';
} else {
// 其他类型消息
contentHtml = `[${item.type}] ${item.content || ''}`;
}
html += `
<div class="chat-record-item">
<div class="chat-record-sender">${item.from_chatroom ? '群聊' : '私聊'}</div>
<div class="chat-record-text">${contentHtml}</div>
<div class="chat-record-time">${new Date(item.msgtime * 1000).toLocaleString()}</div>
</div>
`;
});
contentElement.innerHTML = html;
} catch (error) {
console.error('渲染聊天记录失败:', error);
contentElement.innerHTML = '<div class="error">渲染聊天记录失败: ' + error.message + '</div>';
}
}
// 加载消息
async function loadMessages() {
try {
const chatMessages = document.getElementById('chatMessages');
if (!currentGroupId) {
chatMessages.innerHTML = '<div class="loading">暂无消息</div>';
return;
}
let url = `api/group_messages?limit=${limit}&offset=${currentOffset}&group_id=${currentGroupId}`;
const response = await fetch(url);
const data = await response.json();
if (data.success) {
const messages = data.messages || [];
if (messages.length === 0) {
if (currentOffset === 0) {
chatMessages.innerHTML = '<div class="loading">暂无消息</div>';
}
document.getElementById('messageCountDisplay').textContent = `消息数量: ${data.total || 0}`;
document.getElementById('latestTime').textContent = `最新消息: ${data.lastest_time || '暂无'}`;
return;
}
// 如果是第一次加载(offset=0),清空消息区域
if (currentOffset === 0) {
chatMessages.innerHTML = '';
}
// 将新消息插入到消息区域的开头(向上加载历史消息)
messages.forEach(message => {
const messageDiv = createMessageElement(message);
chatMessages.insertBefore(messageDiv, chatMessages.firstChild);
});
// 更新偏移量,用于下次加载更多
currentOffset += limit;
document.getElementById('messageCountDisplay').textContent = `消息数量: ${data.total || 0}`;
document.getElementById('latestTime').textContent = `最新消息: ${data.lastest_time || '暂无'}`;
} else {
chatMessages.innerHTML = '<div class="error">加载消息失败</div>';
}
} catch (error) {
console.error('加载消息失败:', error);
document.getElementById('chatMessages').innerHTML = '<div class="error">加载消息失败: ' + error.message + '</div>';
}
}
// 创建消息元素
function createMessageElement(message) {
const messageDiv = document.createElement('div');
messageDiv.className = 'message';
// 判断是否为自己的消息
if (message.is_own) {
messageDiv.classList.add('own');
}
const avatar = document.createElement('div');
avatar.className = 'avatar';
avatar.textContent = message.sender_name ? message.sender_name.charAt(0).toUpperCase() : 'U';
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
const senderDiv = document.createElement('div');
senderDiv.className = 'message-sender';
senderDiv.textContent = message.sender_name || '未知用户';
const textDiv = document.createElement('div');
textDiv.className = 'message-text';
// 处理消息内容
if (message.content) {
if (message.type === 'chatrecord' && message.chatrecord_data) {
const chatrecordBtn = document.createElement('button');
chatrecordBtn.className = 'chatrecord-btn';
chatrecordBtn.textContent = message.display_content || '[聊天记录]';
chatrecordBtn.onclick = () => showChatRecordModal(message.chatrecord_data);
textDiv.appendChild(chatrecordBtn);
} else {
textDiv.textContent = message.display_content;
}
} else {
textDiv.textContent = '[空消息]';
}
const timeDiv = document.createElement('div');
timeDiv.className = 'message-time';
timeDiv.textContent = message.formatted_time;
contentDiv.appendChild(senderDiv);
contentDiv.appendChild(textDiv);
contentDiv.appendChild(timeDiv);
messageDiv.appendChild(avatar);
messageDiv.appendChild(contentDiv);
return messageDiv;
}
// 点击模态框外部关闭
window.onclick = function (event) {
const modal = document.getElementById('chatRecordModal');
if (event.target === modal) {
modal.style.display = 'none';
}
}

View File

@@ -0,0 +1,311 @@
/**
* 基础Markdown解析器
* 支持标题、粗体、斜体、列表、链接、图片、代码、引用等基础语法
*/
class MarkdownParser {
constructor() {
this.patterns = {
heading: /^(#{1,6})\s+(.+)$/gm,
bold: /\*\*(.*?)\*\*/g,
italic: /\*(.*?)\*/g,
inlineCode: /`(.*?)`/g,
image: /!\[(.*?)]\((.*?)\)/g,
base64Image: /!\[(.*?)]\((data:image\/[^;]+;base64,[^\)]+)\)/gi,
imageLink: /\[(.*?)]\((.*?\.(?:jpg|jpeg|png|gif|bmp|webp|svg))\s*(?:"(.*?)")?\)/gi,
link: /\[(.*?)]\((?!.*\.(?:jpg|jpeg|png|gif|bmp|webp|svg))(.*?)\)/g,
blockquote: /^>\s+(.+)$/gm,
unorderedList: /^\s*[-*+]\s+(.+)$/gm,
orderedList: /^\d+\.\s+(.+)$/gm,
codeBlock: /```.*?\n([\s\S]*?)```/g,
horizontalRule: /^---$/gm,
comment: /<!--([\s\S]*?)-->/g
};
}
/**
* 解析Markdown文本为HTML
* @param {string} markdown - Markdown文本
* @returns {string} - HTML文本
*/
parse(markdown) {
let html = markdown;
// 处理代码块(先处理,避免其他语法干扰)
html = html.replace(this.patterns.codeBlock, (match, code) => {
// 保留代码内部的换行符,只去除首尾空白
let codeLines = code.split('\n');
let result = '<pre>';
for (let i in codeLines) {
if (codeLines[i].trim() === '') {
continue;
} else {
result += `<code>${codeLines[i]}</code><br>`;
}
}
result += '</pre>';
return result;
});
// 处理块引用
html = html.replace(this.patterns.blockquote, (match, text) => {
return `<blockquote>${this.parseInline(text)}</blockquote>`;
});
// 处理水平分割线
html = html.replace(this.patterns.horizontalRule, '<hr>');
// 处理注释(保留HTML注释)
html = html.replace(this.patterns.comment, '<!--$1-->');
// 处理列表(先处理有序列表,再处理无序列表,避免冲突)
html = this.processLists(html);
// 处理段落(将连续文本包装为段落)
html = this.wrapParagraphs(html);
return html;
}
/**
* 解析行内元素
* @param {string} text - 文本内容
* @returns {string} - 解析后的HTML
*/
parseInline(text) {
let html = text;
// 处理粗体
html = html.replace(this.patterns.bold, '<strong>$1</strong>');
// 处理斜体
html = html.replace(this.patterns.italic, '<em>$1</em>');
// 处理行内代码
html = html.replace(this.patterns.inlineCode, '<code>$1</code>');
// 处理普通图片格式 ![alt](url)
html = html.replace(this.patterns.image, '<img width="100%" src="$2" alt="$1">');
// 处理base64图片格式 ![alt](data:image/...)
html = html.replace(this.patterns.base64Image, '<img width="100%" src="$2" alt="$1">');
// 处理图片链接格式 [alt](image.jpg "title")
html = html.replace(this.patterns.imageLink, (match, alt, src, title) => {
const titleAttr = title ? ` title="${title}"` : '';
return `<img width="100%" src="${src}" alt="${alt}"${titleAttr}>`;
});
// 处理链接
html = html.replace(this.patterns.link, '<a href="$2">$1</a>');
return html;
}
/**
* 处理所有列表,先识别列表类型再处理列表项
* @param {string} html - HTML文本
* @returns {string} - 处理后的HTML
*/
processLists(html) {
// 先识别并标记列表类型
const lines = html.split('\n');
let result = '';
let currentListType = '';
let listItems = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.match(/^\d+\.\s/)) {
// 有序列表项
if (currentListType !== 'ol') {
// 结束前一个列表
if (listItems.length > 0) {
result += this.wrapListItems(listItems, currentListType);
listItems = [];
}
currentListType = 'ol';
}
const content = line.replace(/^\d+\.\s+/, '');
listItems.push(`<li>${this.parseInline(content)}</li>`);
} else if (line.match(/^[-*+]\s/)) {
// 无序列表项
if (currentListType !== 'ul') {
// 结束前一个列表
if (listItems.length > 0) {
result += this.wrapListItems(listItems, currentListType);
listItems = [];
}
currentListType = 'ul';
}
const content = line.replace(/^[-*+]\s+/, '');
listItems.push(`<li>${this.parseInline(content)}</li>`);
} else {
// 非列表项
if (listItems.length > 0) {
result += this.wrapListItems(listItems, currentListType);
listItems = [];
currentListType = '';
}
result += lines[i] + '\n';
}
}
// 处理最后一个列表
if (listItems.length > 0) {
result += this.wrapListItems(listItems, currentListType);
}
return result;
}
/**
* 包装列表项
* @param {Array} listItems - 列表项数组
* @param {string} listType - 列表类型(ul或ol)
* @returns {string} - 包装后的HTML列表
*/
wrapListItems(listItems, listType) {
if (listItems.length === 0) return '';
const listTag = listType === 'ol' ? 'ol' : 'ul';
return `<${listTag}>\n${listItems.join('\n')}\n</${listTag}>\n`;
}
/**
* 包装段落,保留Markdown原有换行
* @param {string} html - HTML文本
* @returns {string} - 包装后的HTML
*/
wrapParagraphs(html) {
const lines = html.split('\n');
let result = '';
let currentParagraph = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmedLine = line.trim();
if (!trimmedLine) {
// 空行,结束当前段落
if (currentParagraph) {
// 检查当前段落是否已经是块级元素
if (!currentParagraph.match(/^<(h[1-6]|blockquote|ul|ol|li|pre|hr)/)) {
result += `<p>${this.parseInline(currentParagraph)}</p>`;
} else {
result += currentParagraph;
}
currentParagraph = '';
}
result += '\n';
} else if (trimmedLine.match(/^<(h[1-6]|blockquote|ul|ol|li|pre|hr)/)) {
// 已经是块级元素,直接添加
if (currentParagraph) {
if (!currentParagraph.match(/^<(h[1-6]|blockquote|ul|ol|li|pre|hr)/)) {
result += `<p>${this.parseInline(currentParagraph)}</p>`;
} else {
result += currentParagraph;
}
currentParagraph = '';
}
result += line + '\n';
} else {
// 保留原有换行,不合并为一行
if (currentParagraph) {
currentParagraph += '\n' + line;
} else {
currentParagraph = line;
}
}
}
// 处理最后一个段落
if (currentParagraph) {
if (!currentParagraph.match(/^<(h[1-6]|blockquote|ul|ol|li|pre|hr)/)) {
result += `<p>${this.parseInline(currentParagraph)}</p>`;
} else {
result += currentParagraph;
}
}
return result;
}
/**
* HTML转义,支持保留<br>标签
* @param {string} text - 原始文本
* @returns {string} - 转义后的文本
*/
escapeHTML(text) {
// 首先转义所有HTML特殊字符
const escapedText = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
// 然后恢复<br>标签的功能
return escapedText
.replace(/&lt;br\s*\/?&gt;/gi, '<br>')
.replace(/&lt;br\s*\/?&gt;/gi, '<br>')
.replace(/\\n/g, '<br>'); // 也支持\n换行符
}
/**
* 专门解析表格(与MergeTableParser配合使用)
* @param {string} markdown - Markdown表格文本
* @returns {string} - HTML表格
*/
parseTable(markdown) {
// 使用MergeTableParser处理表格
if (typeof MergeTableParser !== 'undefined') {
const tableParser = new MergeTableParser();
return tableParser.parseMarkdownTable(markdown);
}
return markdown;
}
/**
* 完整解析Markdown,包含表格处理
* @param {string} markdown - 完整Markdown文本
* @returns {string} - 完整HTML
*/
parseFull(markdown) {
let html = markdown;
// 先处理标题,避免表格干扰
html = html.replace(this.patterns.heading, (match, hashes, text) => {
const level = hashes.length;
return `<h${level}>${this.parseInline(text)}</h${level}>`;
});
// 再提取并处理表格
const tableRegex = /(\|.*?\|\s*\n)+/g;
html = html.replace(tableRegex, (match) => {
// 检查是否是有效的表格格式
const lines = match.trim().split('\n');
if (lines.length >= 2 && lines.every(line => line.trim().startsWith('|') && line.trim().endsWith('|'))) {
return this.parseTable(match);
}
return match;
});
// 然后处理其他Markdown语法
return this.parse(html);
}
}
// 导出为全局变量
if (typeof window !== 'undefined') {
window.MarkdownParser = MarkdownParser;
}
// 使用示例
if (typeof module !== 'undefined' && module.exports) {
module.exports = MarkdownParser;
}

View File

@@ -0,0 +1,273 @@
/**
* 支持合并单元格的Markdown表格解析器
* 语法说明:
* ^n# - 向上合并n个单元格
* >n# - 向左合并n个单元格
* ^n>m# - 同时向上合并n个单元格,向左合并m个单元格
*/
class MergeTableParser {
constructor() {
this.patterns = {
mergeUp: /\^(\d+)#/, // ^2#
mergeLeft: />(\d+)#/, // >3#
mergeBoth: /\^(\d+)>(\d+)#/, // ^2>3#
};
}
/**
* 解析Markdown表格并转换为HTML表格
* @param {string} markdown - Markdown表格文本
* @returns {string} - HTML表格
*/
parseMarkdownTable(markdown) {
const lines = markdown.trim().split('\n');
const tableData = this.parseTableLines(lines);
return this.generateHTMLTable(tableData);
}
/**
* 解析表格行数据
* @param {string[]} lines - 表格行数组
* @returns {Object} - 表格数据对象
*/
parseTableLines(lines) {
const rows = [];
let headers = [];
let separatorIndex = -1;
// 解析表头和分隔符
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.startsWith('|') && line.endsWith('|')) {
const cells = line.slice(1, -1).split('|').map(cell => cell.trim());
if (line.includes('---') || line.includes('===')) {
separatorIndex = i;
continue;
}
if (separatorIndex === -1) {
headers = cells;
} else {
rows.push(cells);
}
}
}
return { headers, rows, separatorIndex };
}
/**
* 生成HTML表格
* @param {Object} tableData - 表格数据
* @returns {string} - HTML表格字符串
*/
generateHTMLTable(tableData) {
const { headers, rows } = tableData;
const skipMap = new Map(); // 用于标记需要跳过的单元格
let html = '<table border="1" style="border-collapse: collapse; width: 100%;">\n';
// 生成表头(支持合并单元格)
if (headers.length > 0) {
html += ' <thead>\n <tr>\n';
// 处理表头合并单元格
headers.forEach((header, colIndex) => {
// 检查当前单元格是否应该被跳过
const key = `-1,${colIndex}`; // 表头行索引为-1
if (skipMap.has(key)) {
return;
}
const mergeInfo = this.parseMergeSyntax(header);
let cellHtml = '';
if (mergeInfo.rowspan > 1 || mergeInfo.colspan > 1) {
cellHtml = ` <th rowspan="${mergeInfo.rowspan}" colspan="${mergeInfo.colspan}">${this.escapeHTML(mergeInfo.content)}</th>\n`;
// 标记被合并的表头单元格
this.markMergedCells(-1, colIndex, mergeInfo, skipMap);
} else {
cellHtml = ` <th>${this.escapeHTML(header)}</th>\n`;
}
html += cellHtml;
});
html += ' </tr>\n </thead>\n';
}
// 生成表格主体
html += ' <tbody>\n';
// 第一遍:标记所有需要合并的单元格
rows.forEach((row, rowIndex) => {
row.forEach((cell, colIndex) => {
const mergeInfo = this.parseMergeSyntax(cell);
if (mergeInfo.rowspan > 1 || mergeInfo.colspan > 1) {
this.markMergedCells(rowIndex, colIndex, mergeInfo, skipMap);
}
});
});
// 第二遍:生成HTML,跳过被合并的单元格
rows.forEach((row, rowIndex) => {
html += ' <tr>\n';
let colOffset = 0; // 列偏移量,用于处理被跳过的单元格
row.forEach((cell, colIndex) => {
// 检查当前单元格是否应该被跳过
const key = `${rowIndex},${colIndex}`;
if (skipMap.has(key)) {
colOffset++;
return;
}
const mergeInfo = this.parseMergeSyntax(cell);
let cellHtml = '';
if (mergeInfo.rowspan > 1 || mergeInfo.colspan > 1) {
cellHtml = ` <td rowspan="${mergeInfo.rowspan}" colspan="${mergeInfo.colspan}">${this.escapeHTML(mergeInfo.content)}</td>\n`;
} else {
cellHtml = ` <td>${this.escapeHTML(cell)}</td>\n`;
}
html += cellHtml;
});
html += ' </tr>\n';
});
html += ' </tbody>\n</table>';
return html;
}
/**
* 解析合并语法
* @param {string} cell - 单元格内容
* @returns {Object} - 合并信息
*/
parseMergeSyntax(cell) {
const result = {
content: cell,
rowspan: 1,
colspan: 1,
shouldSkip: false
};
// 检查合并语法
const bothMatch = cell.match(this.patterns.mergeBoth);
const upMatch = cell.match(this.patterns.mergeUp);
const leftMatch = cell.match(this.patterns.mergeLeft);
if (bothMatch) {
result.rowspan = parseInt(bothMatch[1]) + 1;
result.colspan = parseInt(bothMatch[2]) + 1;
result.content = cell.replace(this.patterns.mergeBoth, '').trim();
} else if (upMatch) {
result.rowspan = parseInt(upMatch[1]) + 1;
result.content = cell.replace(this.patterns.mergeUp, '').trim();
} else if (leftMatch) {
result.colspan = parseInt(leftMatch[1]) + 1;
result.content = cell.replace(this.patterns.mergeLeft, '').trim();
}
return result;
}
/**
* 标记被合并的单元格
* @param {number} rowIndex - 行索引
* @param {number} colIndex - 列索引
* @param {Object} mergeInfo - 合并信息
* @param {Map} skipMap - 跳过映射
*/
markMergedCells(rowIndex, colIndex, mergeInfo, skipMap) {
for (let r = 0; r < mergeInfo.rowspan; r++) {
for (let c = 0; c < mergeInfo.colspan; c++) {
if (r === 0 && c === 0) continue; // 跳过主单元格
// 处理表头合并(行索引为-1)
const targetRow = rowIndex === -1 ? -1 : rowIndex + r;
const key = `${targetRow},${colIndex + c}`;
skipMap.set(key, true);
}
}
}
/**
* HTML转义,支持保留<br>标签
* @param {string} text - 原始文本
* @returns {string} - 转义后的文本
*/
escapeHTML(text) {
// 首先转义所有HTML特殊字符
const escapedText = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
// 然后恢复<br>标签的功能
return escapedText
.replace(/&lt;br\s*\/?&gt;/gi, '<br>')
.replace(/&lt;br\s*\/?&gt;/gi, '<br>')
.replace(/\\n/g, '<br>'); // 也支持\n换行符
}
/**
* 将HTML表格转换回Markdown格式
* @param {string} html - HTML表格
* @returns {string} - Markdown表格
*/
htmlToMarkdown(html) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const table = doc.querySelector('table');
if (!table) return '';
let markdown = '';
const rows = table.querySelectorAll('tr');
rows.forEach((row, rowIndex) => {
const cells = row.querySelectorAll('th, td');
let rowMarkdown = '|';
cells.forEach(cell => {
const rowspan = parseInt(cell.getAttribute('rowspan') || '1');
const colspan = parseInt(cell.getAttribute('colspan') || '1');
let content = cell.textContent.trim();
// 添加合并语法
if (rowspan > 1) {
content += ` ^${rowspan - 1}`;
}
if (colspan > 1) {
content += ` >${colspan - 1}`;
}
rowMarkdown += ` ${content} |`;
});
markdown += rowMarkdown + '\n';
// 添加分隔符行(仅在第一行后)
if (rowIndex === 0) {
markdown += '|' + cells.map(() => '---').join('|') + '|\n';
}
});
return markdown;
}
}
// 导出为全局变量
if (typeof window !== 'undefined') {
window.MergeTableParser = MergeTableParser;
}
// 使用示例
if (typeof module !== 'undefined' && module.exports) {
module.exports = MergeTableParser;
}

1015
static/js/qa.js Normal file

File diff suppressed because it is too large Load Diff

191
static/js/weekly_report.js Normal file
View File

@@ -0,0 +1,191 @@
togglePreview = function (mode) {
const previewBtn = document.getElementById('preview-toggle');
const sourceBtn = document.getElementById('source-toggle');
const reportContent = document.getElementById('report-content');
const markdownSource = document.getElementById('markdown-source');
const sourceContent = document.getElementById('source-content');
if (mode === 'preview') {
previewBtn.classList.add('active');
sourceBtn.classList.remove('active');
reportContent.style.display = 'block';
markdownSource.style.display = 'none';
} else {
previewBtn.classList.remove('active');
sourceBtn.classList.add('active');
reportContent.style.display = 'none';
markdownSource.style.display = 'block';
// 显示原始Markdown源代码
const markdown = reportContent.getAttribute('data-markdown');
sourceContent.textContent = markdown;
}
};
// 设置默认周次为当前周
function getCurrentWeek() {
const now = new Date();
const startOfYear = new Date(now.getFullYear(), 0, 1);
const days = Math.floor((now - startOfYear) / (24 * 60 * 60 * 1000));
const weekNumber = Math.ceil((days + 1) / 7);
return `${now.getFullYear()}-W${weekNumber.toString().padStart(2, '0')}`;
}
// 页面加载时设置默认周次
document.addEventListener('DOMContentLoaded', function () {
document.getElementById('week').value = getCurrentWeek();
});
function showLoading() {
document.getElementById('loading').style.display = 'block';
document.getElementById('report-container').style.display = 'none';
document.getElementById('error').style.display = 'none';
}
function hideLoading() {
document.getElementById('loading').style.display = 'none';
}
function showError(message) {
document.getElementById('error').style.display = 'block';
document.getElementById('error').textContent = message;
}
function generateCurrentWeekReport() {
document.getElementById('week').value = getCurrentWeek();
generateReport();
}
function generateReport() {
const week = document.getElementById('week').value;
if (!week) {
showError('请选择周次');
return;
}
// 转换格式:YYYY-WWW 到 YYYY-WW
const weekFormatted = week.replace('W', '');
showLoading();
fetch(`api/reports/weekly?week=${weekFormatted}`)
.then(response => response.json())
.then(data => {
hideLoading();
if (data.success) {
// 保存原始Markdown内容到data属性
document.getElementById('report-content').setAttribute('data-markdown', data.report);
// 使用专业的Markdown解析器将Markdown转换为HTML并显示
const htmlContent = parseMarkdownWithTables(data.report);
document.getElementById('report-content').innerHTML = htmlContent;
// 重置为预览模式
togglePreview('preview');
document.getElementById('report-container').style.display = 'block';
// 显示统计数据
if (data.statistics) {
displayStatistics(data.statistics);
document.getElementById('statistics').style.display = 'block';
// 显示分类图表
if (data.statistics.category_breakdown) {
displayCategoryChart(data.statistics.category_breakdown);
document.getElementById('category-chart').style.display = 'block';
}
}
} else {
showError('生成周报失败:' + data.error);
}
})
.catch(error => {
hideLoading();
showError('网络错误:' + error.message);
});
}
function displayStatistics(stats) {
const statGrid = document.getElementById('stat-grid');
statGrid.innerHTML = '';
const statistics = [
{label: '本周消息量', value: stats.week_message_count, unit: '条'},
{label: '上周消息量', value: stats.last_week_message_count, unit: '条'},
{label: '消息变化率', value: stats.message_change_rate.toFixed(1), unit: '%'},
{label: '本周活跃用户', value: stats.week_active_users, unit: '人'},
{label: '群组数量', value: stats.group_count, unit: '个'},
{label: '成员总数', value: stats.member_count, unit: '人'},
{label: '问答记录数', value: stats.qa_count, unit: '条'}
];
statistics.forEach(stat => {
const statItem = document.createElement('div');
statItem.className = 'stat-item';
statItem.innerHTML = `
<div class="stat-value">${stat.value}${stat.unit}</div>
<div class="stat-label">${stat.label}</div>
`;
statGrid.appendChild(statItem);
});
}
function displayCategoryChart(categories) {
const categoryItems = document.getElementById('category-items');
categoryItems.innerHTML = '';
Object.entries(categories).forEach(([category, percentage]) => {
const categoryItem = document.createElement('div');
categoryItem.className = 'category-item';
categoryItem.innerHTML = `
<span style="min-width: 120px;">${category}</span>
<div class="category-bar" style="width: ${percentage * 2}px;"></div>
<span style="min-width: 40px; text-align: right;">${percentage}%</span>
`;
categoryItems.appendChild(categoryItem);
});
}
function exportReport() {
const reportContent = document.getElementById('report-content').getAttribute('data-markdown');
if (!reportContent) {
showError('请先生成周报');
return;
}
const blob = new Blob([reportContent], {type: 'text/markdown'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `运营周报_${document.getElementById('week').value}.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// 页面加载时自动生成本周周报
window.addEventListener('load', function () {
generateCurrentWeekReport();
});
// 支持回车键生成报告
document.getElementById('week').addEventListener('keypress', function (e) {
if (e.key === 'Enter') {
generateReport();
}
});
// 使用专业的Markdown解析器解析Markdown文本,支持表格
function parseMarkdownWithTables(markdown) {
if (typeof MarkdownParser !== 'undefined' && typeof MergeTableParser !== 'undefined') {
const parser = new MarkdownParser();
return parser.parseFull(markdown);
} else {
// 回退到简单的markdown渲染
return markdownToHtml(markdown);
}
}

View File

@@ -0,0 +1,284 @@
// 周报生成器 v3版本 JavaScript
togglePreview = function (mode) {
const previewBtn = document.getElementById('preview-toggle');
const sourceBtn = document.getElementById('source-toggle');
const reportContent = document.getElementById('report-content');
const markdownSource = document.getElementById('markdown-source');
const sourceContent = document.getElementById('source-content');
if (mode === 'preview') {
previewBtn.classList.add('active');
sourceBtn.classList.remove('active');
reportContent.style.display = 'block';
markdownSource.style.display = 'none';
} else {
previewBtn.classList.remove('active');
sourceBtn.classList.add('active');
reportContent.style.display = 'none';
markdownSource.style.display = 'block';
// 显示原始Markdown源代码
const markdown = reportContent.getAttribute('data-markdown');
sourceContent.textContent = markdown;
}
};
// 页面加载时获取可用的周次列表
document.addEventListener('DOMContentLoaded', function () {
loadAvailableWeeks();
});
// 加载可用的周次列表
function loadAvailableWeeks() {
fetch('/v3/api/weekly_report/weeks')
.then(response => response.json())
.then(data => {
if (data.success) {
const weekSelect = document.getElementById('week');
weekSelect.innerHTML = '<option value="">请选择周次...</option>';
data.data.weeks.forEach(week => {
const option = document.createElement('option');
option.value = week.week;
option.textContent = week.display;
weekSelect.appendChild(option);
});
// 设置默认选择为当前周
const currentDate = new Date();
const year = currentDate.getFullYear();
const weekNumber = getWeekNumber(currentDate);
const currentWeek = `${year}-${weekNumber.toString().padStart(2, '0')}`;
weekSelect.value = currentWeek;
} else {
console.error('获取周次列表失败:', data.error);
}
})
.catch(error => {
console.error('网络错误:', error);
});
}
// 获取周数
function getWeekNumber(date) {
const firstDayOfYear = new Date(date.getFullYear(), 0, 1);
const pastDaysOfYear = (date - firstDayOfYear) / 86400000;
return Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7);
}
function showLoading(message = '正在生成周报,请稍候...') {
document.getElementById('loading').style.display = 'block';
document.getElementById('loading-details').textContent = message;
document.getElementById('report-container').style.display = 'none';
document.getElementById('error').style.display = 'none';
}
function hideLoading() {
document.getElementById('loading').style.display = 'none';
document.getElementById('loading-details').textContent = '';
}
function showError(message) {
document.getElementById('error').style.display = 'block';
document.getElementById('error').textContent = message;
}
function generateCurrentWeekReport() {
const currentDate = new Date();
const year = currentDate.getFullYear();
const weekNumber = getWeekNumber(currentDate);
const currentWeek = `${year}-${weekNumber.toString().padStart(2, '0')}`;
document.getElementById('week').value = currentWeek;
generateReport();
}
function generateReport() {
const week = document.getElementById('week').value;
const useDeepseek = document.getElementById('use-deepseek').checked;
if (!week) {
showError('请选择周次');
return;
}
showLoading(useDeepseek ? '正在生成周报(使用DeepSeek AI增强)...' : '正在生成周报...');
fetch('/v3/api/weekly_report/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
week: week,
use_deepseek: useDeepseek
})
})
.then(response => response.json())
.then(data => {
hideLoading();
if (data.success) {
// 保存原始Markdown内容到data属性
document.getElementById('report-content').setAttribute('data-markdown', data.data.report);
// 使用专业的Markdown解析器将Markdown转换为HTML并显示
const htmlContent = parseMarkdownWithTables(data.data.report);
document.getElementById('report-content').innerHTML = htmlContent;
// 重置为预览模式
togglePreview('preview');
document.getElementById('report-container').style.display = 'block';
// 启用图片导出按钮
updateImageExportButtonState();
// 显示统计数据
if (data.data.statistics) {
// 显示分类图表
if (data.data.statistics.category_breakdown) {
displayCategoryChart(data.data.statistics.category_breakdown);
document.getElementById('category-chart').style.display = 'block';
}
}
} else {
showError('生成周报失败:' + data.error);
}
})
.catch(error => {
hideLoading();
showError('网络错误:' + error.message);
});
}
function displayCategoryChart(categories) {
const categoryItems = document.getElementById('category-items');
categoryItems.innerHTML = '';
Object.entries(categories).forEach(([category, percentage]) => {
const categoryItem = document.createElement('div');
categoryItem.className = 'category-item';
categoryItem.innerHTML = `
<span style="min-width: 120px;">${category}</span>
<div class="category-bar" style="width: ${percentage * 2}px;"></div>
<span style="min-width: 40px; text-align: right;">${percentage}%</span>
`;
categoryItems.appendChild(categoryItem);
});
}
function exportReport() {
const reportContent = document.getElementById('report-content').getAttribute('data-markdown');
if (!reportContent) {
showError('请先生成周报');
return;
}
const week = document.getElementById('week').value;
const blob = new Blob([reportContent], {type: 'text/markdown'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `运营周报_v3_${week}.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* 导出报告为图片
*/
async function exportReportAsImage() {
const exportBtn = document.getElementById('image-export-btn');
const reportContent = document.getElementById('report-content');
if (!reportContent || reportContent.innerHTML.trim() === '') {
alert('请先生成周报内容');
return;
}
if (typeof convertAndDownload === 'undefined') {
alert('图片导出功能未加载,请刷新页面重试');
return;
}
try {
// 显示加载中状态
const originalText = exportBtn.textContent;
exportBtn.disabled = true;
exportBtn.textContent = '导出中...';
// 获取当前周数 - 修复选择器
const week = document.getElementById('week').value || '当前';
const filename = `运营周报_v3_${week}.png`;
console.log('开始导出图片...');
console.log('目标元素:', reportContent);
console.log('文件名:', filename);
await convertAndDownload(reportContent, filename, {
scale: 2,
backgroundColor: '#ffffff',
useCORS: true,
allowTaint: false
});
console.log('图片导出成功');
alert('图片导出成功!');
} catch (error) {
console.error('图片导出失败:', error);
alert(`图片导出失败: ${error.message || error}`);
} finally {
// 恢复按钮状态
exportBtn.disabled = false;
exportBtn.textContent = '导出图片';
}
}
// 启用/禁用图片导出按钮
function updateImageExportButtonState() {
const reportContent = document.getElementById('report-content');
const imageExportBtn = document.getElementById('image-export-btn');
if (reportContent && reportContent.innerHTML.trim()) {
imageExportBtn.disabled = false;
imageExportBtn.classList.remove('disabled');
} else {
imageExportBtn.disabled = true;
imageExportBtn.classList.add('disabled');
}
}
// 支持回车键生成报告
document.getElementById('week').addEventListener('keypress', function (e) {
if (e.key === 'Enter') {
generateReport();
}
});
// 使用专业的Markdown解析器解析Markdown文本,支持表格
function parseMarkdownWithTables(markdown) {
if (typeof MarkdownParser !== 'undefined' && typeof MergeTableParser !== 'undefined') {
const parser = new MarkdownParser();
return parser.parseFull(markdown);
} else {
// 回退到简单的markdown渲染
return markdownToHtml(markdown);
}
}
// 简单的Markdown转HTML函数(备用)
function markdownToHtml(markdown) {
return markdown
.replace(/\n/g, '<br>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/^#\s+(.*)$/gm, '<h1>$1</h1>')
.replace(/^##\s+(.*)$/gm, '<h2>$1</h2>')
.replace(/^###\s+(.*)$/gm, '<h3>$1</h3>')
.replace(/`(.*?)`/g, '<code>$1</code>');
}

View File

@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>运营日报 - 致远速联</title>
<link rel="stylesheet" href="/static/css/daily_report.css">
</head>
<body>
<div class="container">
<div class="nav">
<a href="./" class="nav-btn">聊天记录</a>
<a href="./qa" class="nav-btn">问答管理</a>
<a href="./daily_report" class="nav-btn active">日报生成</a>
<a href="./weekly_report" class="nav-btn">周报生成</a>
</div>
<div class="header">
<h1>运营日报生成器</h1>
<p>致远速联高端社群运营数据分析</p>
</div>
<div class="controls">
<div class="control-group">
<label for="date">选择日期:</label>
<input type="date" id="date" value="">
<button class="btn" onclick="generateReport()">生成日报</button>
<button class="btn" onclick="generateTodayReport()">今日日报</button>
<button class="export-btn" onclick="exportReport()">导出日报</button>
</div>
</div>
<div id="report-container" class="report-container" style="display: none;">
<div class="preview-controls">
<button id="preview-toggle" class="preview-btn active" onclick="togglePreview('preview')">预览模式</button>
<button id="source-toggle" class="preview-btn" onclick="togglePreview('source')">源代码模式</button>
</div>
<div id="report-content" class="report-content"></div>
<div id="markdown-source" class="markdown-source" style="display: none;">
<pre><code id="source-content"></code></pre>
</div>
<div id="statistics" class="statistics" style="display: none;">
<h3>详细统计数据</h3>
<div class="stat-grid" id="stat-grid"></div>
</div>
</div>
<div id="loading" class="loading" style="display: none;">
<p>正在生成日报,请稍候...</p>
</div>
<div id="error" class="error" style="display: none;"></div>
</div>
<script src="/static/js/markdown-parser.js"></script>
<script src="/static/js/markdown-plugin-table.js"></script>
<script src="/static/js/daily_report.js"></script>
</body>
</html>

92
templates/index.html Normal file
View File

@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>企业微信聊天记录查看器</title>
<link rel="stylesheet" href="/static/css/index.css">
</head>
<body>
<div class="container">
<div class="nav">
<a href="./" class="nav-btn active">聊天记录</a>
<a href="./qa" class="nav-btn">问答管理</a>
<a href="./daily_report" class="nav-btn">日报生成</a>
<a href="./weekly_report" class="nav-btn">周报生成</a>
</div>
<div class="header">
<h1>企业微信聊天记录查看器</h1>
<p>基于企业微信会话数据库的实时聊天记录展示</p>
</div>
<div class="stats-grid" id="statsGrid">
<div class="stat-card">
<div class="stat-number" id="userCount">0</div>
<div class="stat-label">用户数量</div>
</div>
<div class="stat-card">
<div class="stat-number" id="groupCount">0</div>
<div class="stat-label">群组数量</div>
</div>
<div class="stat-card">
<div class="stat-number" id="messageCount">0</div>
<div class="stat-label">消息总数</div>
</div>
<div class="stat-card">
<div class="stat-number" id="fileCount">0</div>
<div class="stat-label">文件数量</div>
</div>
</div>
<div class="view-switcher">
<button class="view-btn active" id="viewGroups">群组列表</button>
</div>
<div class="chat-container">
<div class="sidebar">
<div id="groupsView">
<ul class="group-list" id="groupList">
<li>加载中...</li>
</ul>
</div>
</div>
<div class="chat-area">
<div class="chat-header">
<div class="current-group" id="currentGroup">全部聊天记录</div>
<div class="group-stats" id="groupStats">
<span id="messageCountDisplay">消息数量: 加载中...</span>
<span id="latestTime">最新消息: 加载中...</span>
</div>
</div>
<div class="controls">
<button class="load-more" onclick="loadMoreMessages()">加载更多历史消息</button>
</div>
<div class="chat-messages" id="chatMessages">
<div class="loading">正在加载消息...</div>
</div>
</div>
</div>
</div>
<!-- 聊天记录模态框 -->
<div id="chatRecordModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">聊天记录</h3>
<span class="close" onclick="closeChatRecordModal()">&times;</span>
</div>
<div class="modal-body">
<div id="chatRecordContent" class="chat-record-content">
<!-- 聊天记录内容将在这里动态生成 -->
</div>
</div>
</div>
</div>
<script src="/static/js/index.js"></script>
</body>
</html>

516
templates/markdown.html Normal file

File diff suppressed because one or more lines are too long

156
templates/qa.html Normal file
View File

@@ -0,0 +1,156 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>问答管理系统</title>
<link rel="stylesheet" href="/static/css/qa.css">
</head>
<body>
<div class="container">
<div class="nav">
<a href="./" class="nav-btn">聊天记录</a>
<a href="./qa" class="nav-btn active">问答管理</a>
<a href="./daily_report" class="nav-btn">日报生成</a>
<a href="./weekly_report" class="nav-btn">周报生成</a>
</div>
<div class="header">
<h1>问答管理系统</h1>
<p>管理问题和答案的关联关系</p>
</div>
<div class="controls">
<div class="filter-section">
<div class="filter-header">
<h3>搜索条件</h3>
<div class="filter-actions">
<button class="btn btn-primary" onclick="advancedSearch()">搜索</button>
<button class="btn btn-secondary" onclick="searchMessages()">快速搜索</button>
<button class="btn btn-outline" onclick="clearFilters()">清空</button>
</div>
</div>
<div class="filter-grid">
<div class="filter-item search-container">
<label for="searchInput">关键词搜索</label>
<div class="search-input-wrapper">
<input type="text" id="searchInput" placeholder="搜索群组ID或问题内容..."
oninput="showSearchSuggestions()" onfocus="showSearchSuggestions()">
<div id="searchSuggestions" class="search-suggestions"></div>
</div>
<div id="searchHistory" class="search-history"></div>
</div>
<div class="filter-item">
<label for="groupFilter">群组ID</label>
<select id="groupFilter">
<option value="">全部群组</option>
</select>
</div>
<div class="filter-item">
<label for="startDateFilter">开始时间</label>
<input type="date" id="startDateFilter">
</div>
<div class="filter-item">
<label for="endDateFilter">结束时间</label>
<input type="date" id="endDateFilter">
</div>
</div>
</div>
<div class="action-section">
<div class="stats-section">
<div id="statsInfo" class="stats-info">正在加载...</div>
</div>
<div class="create-section">
<button class="btn btn-success" onclick="openCreateModal()">创建新QA</button>
</div>
</div>
</div>
<!-- 批量操作区域 -->
<div id="batchActions" class="batch-actions" style="display: none;">
<div class="batch-header">
<div class="batch-select">
<input type="checkbox" id="selectAll" onchange="toggleSelectAll()">
<label for="selectAll">全选</label>
</div>
<div class="batch-info" id="batchInfo">已选择 0 条记录</div>
<div class="batch-buttons">
<button class="btn btn-danger" id="batchDeleteBtn" onclick="batchDeleteQA()" disabled>批量删除</button>
</div>
</div>
</div>
<div class="qa-list" id="qaList">
<div class="loading">加载中...</div>
</div>
</div>
<!-- 创建/编辑QA模态框 -->
<div id="qaModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modalTitle">创建QA记录</h2>
<span class="close" onclick="closeModal()">&times;</span>
</div>
<form id="qaForm">
<div class="form-group">
<label for="groupId">群组ID:</label>
<select id="groupId">
<option value="">选择群组(可选)</option>
</select>
</div>
<div class="message-selector">
<h4>选择问题消息</h4>
<div class="search-box">
<input type="text" id="questionSearch" placeholder="搜索问题消息..."
oninput="searchQuestionMessages()">
</div>
<div class="search-results" id="questionResults"></div>
<div class="selected-messages">
<h5>已选择的问题消息:</h5>
<div id="selectedQuestions"></div>
</div>
</div>
<div class="message-selector">
<h4>选择答案消息</h4>
<div class="search-box">
<input type="text" id="answerSearch" placeholder="搜索答案消息..." oninput="searchAnswerMessages()">
</div>
<div class="search-results" id="answerResults"></div>
<div class="selected-messages">
<h5>已选择的答案消息:</h5>
<div id="selectedAnswers"></div>
</div>
</div>
<div style="text-align: right; margin-top: 20px;">
<button type="button" class="btn btn-primary" onclick="saveQA()">保存</button>
<button type="button" class="btn" onclick="closeModal()" style="margin-left: 10px;">取消</button>
</div>
</form>
</div>
</div>
<!-- 预览模态框 -->
<div id="previewModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>QA预览</h2>
<span class="close" onclick="closePreviewModal()">&times;</span>
</div>
<div id="previewContent"></div>
</div>
</div>
<script src="/static/js/qa.js"></script>
</body>
</html>

View File

@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>运营周报 - 致远速联</title>
<link rel="stylesheet" href="/static/css/weekly_report.css">
</head>
<body>
<div class="container">
<div class="nav">
<a href="./" class="nav-btn">聊天记录</a>
<a href="./qa" class="nav-btn">问答管理</a>
<a href="./daily_report" class="nav-btn">日报生成</a>
<a href="./weekly_report" class="nav-btn active">周报生成</a>
</div>
<div class="header">
<h1>运营周报生成器</h1>
<p>致远速联高端社群运营周度数据分析</p>
</div>
<div class="controls">
<div class="control-group">
<label for="week">选择周次:</label>
<input type="week" id="week" value="">
<button class="btn" onclick="generateReport()">生成周报</button>
<button class="btn" onclick="generateCurrentWeekReport()">本周周报</button>
<button class="export-btn" onclick="exportReport()">导出报告</button>
</div>
</div>
<div id="report-container" class="report-container" style="display: none;">
<div class="preview-controls">
<button id="preview-toggle" class="preview-btn active" onclick="togglePreview('preview')">预览模式</button>
<button id="source-toggle" class="preview-btn" onclick="togglePreview('source')">源代码模式</button>
</div>
<div id="report-content" class="report-content"></div>
<div id="markdown-source" class="markdown-source" style="display: none;">
<pre><code id="source-content"></code></pre>
</div>
<div id="statistics" class="statistics" style="display: none;">
<h3>详细统计数据</h3>
<div class="stat-grid" id="stat-grid"></div>
<div id="category-chart" class="category-chart" style="display: none;">
<h4>问题分类分布</h4>
<div id="category-items"></div>
</div>
</div>
</div>
<div id="loading" class="loading" style="display: none;">
<p>正在生成周报,请稍候...</p>
</div>
<div id="error" class="error" style="display: none;"></div>
</div>
<script src="/static/js/markdown-parser.js"></script>
<script src="/static/js/markdown-plugin-table.js"></script>
<script src="/static/js/weekly_report.js"></script>
</body>
</html>

View File

@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>运营周报 v3 - 致远速联</title>
<link rel="stylesheet" href="/static/css/weekly_report.css">
<style>
.deepseek-toggle {
margin-left: 20px;
}
.deepseek-toggle label {
margin-right: 10px;
}
.image-export-btn {
background-color: #28a745;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
margin-left: 10px;
}
.image-export-btn:hover {
background-color: #218838;
}
.image-export-btn:disabled {
background-color: #6c757d;
cursor: not-allowed;
}
</style>
</head>
<body>
<div class="container">
<div class="nav">
<a href="./" class="nav-btn">聊天记录</a>
<a href="./qa" class="nav-btn">问答管理</a>
<a href="./daily_report" class="nav-btn">日报生成</a>
<a href="./weekly_report" class="nav-btn">周报生成</a>
<a href="./weekly_report_v3" class="nav-btn active">周报生成 v3</a>
</div>
<div class="header">
<h1>运营周报生成器 v3</h1>
<p>基于需求文档样式的周报生成,支持DeepSeek AI增强</p>
<div class="api-version">
<strong>API版本:v3</strong> - 基于需求文档样式,包含群组互动分析、热点话题、客户问题分类等深度分析
</div>
</div>
<div class="controls">
<div class="control-group">
<label for="week">选择周次:</label>
<select id="week" style="width: 200px;">
<option value="">请选择周次...</option>
</select>
<div class="deepseek-toggle">
<label>
<input type="checkbox" id="use-deepseek" checked> 使用DeepSeek AI增强
</label>
</div>
<button class="btn" onclick="generateReport()">生成周报</button>
<button class="btn" onclick="generateCurrentWeekReport()">本周周报</button>
<button class="export-btn" onclick="exportReport()">导出报告</button>
<button class="image-export-btn" onclick="exportReportAsImage()" id="image-export-btn" disabled>导出图片</button>
</div>
</div>
<div id="report-container" class="report-container" style="display: none;">
<div class="preview-controls">
<button id="preview-toggle" class="preview-btn active" onclick="togglePreview('preview')">预览模式</button>
<button id="source-toggle" class="preview-btn" onclick="togglePreview('source')">源代码模式</button>
</div>
<div id="report-content" class="report-content"></div>
<div id="markdown-source" class="markdown-source" style="display: none;">
<pre><code id="source-content"></code></pre>
</div>
</div>
<div id="loading" class="loading" style="display: none;">
<p>正在生成周报,请稍候...</p>
<div id="loading-details" style="margin-top: 10px; font-size: 12px; color: #666;"></div>
</div>
<div id="error" class="error" style="display: none;"></div>
</div>
<script src="/static/js/html2canvas.min.js"></script>
<script src="/static/js/markdown-parser.js"></script>
<script src="/static/js/markdown-plugin-table.js"></script>
<script src="/static/js/h2p.js"></script>
<script src="/static/js/weekly_report_v3.js"></script>
</body>
</html>

398
tools.py Normal file
View File

@@ -0,0 +1,398 @@
import time
import datetime
from typing import Union, Optional
import sqlite3
from file import ObjectFileManager, LocalFileManager
class DatabaseManager:
"""数据库管理类"""
def __init__(self, db_path, online_db_uri=None):
self.db_path = db_path
self.online_db_uri = online_db_uri
self.obj = ObjectFileManager()
self.lfm = LocalFileManager("db")
def upload_db(self):
file = self.lfm.get_file("data")
self.obj.upload_file(self.online_db_uri, file)
print("DB upload success")
def download_db(self):
file = self.obj.get_file(self.online_db_uri)
self.lfm.upload_file("data", file)
def get_connection(self):
"""获取数据库连接"""
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
def execute_event(self, query, params=None):
conn = self.get_connection()
cursor = conn.cursor()
try:
if params:
cursor.executemany(query, params)
else:
cursor.execute(query)
conn.commit()
conn.close()
return True
except Exception as e:
print(e)
conn.close()
return False
def execute_query(self, query, params=None, fetch_all=True):
"""执行查询并返回结果"""
conn = self.get_connection()
cursor = conn.cursor()
try:
if params:
cursor.execute(query, params)
else:
cursor.execute(query)
if fetch_all:
result = cursor.fetchall()
else:
result = cursor.fetchone()
conn.close()
return result
except Exception as e:
print(e)
conn.close()
raise e
class TimeUtils:
"""
时间工具类
支持时间戳、时间字符串等多种格式的时间处理,以及相互转换和时间计算
"""
# 常用时间格式
FORMAT_DATETIME = "%Y-%m-%d %H:%M:%S"
FORMAT_DATE = "%Y-%m-%d"
FORMAT_TIME = "%H:%M:%S"
FORMAT_DATETIME_COMPACT = "%Y%m%d%H%M%S"
FORMAT_DATE_COMPACT = "%Y%m%d"
def __init__(self, time_input: Optional[Union[str, int, float, datetime.datetime]] = None):
"""
初始化时间工具
Args:
time_input: 时间输入,可以是:
- 时间戳(整数或浮点数)
- 时间字符串(支持多种格式)
- datetime对象
- None(使用当前时间)
"""
if time_input is None:
self.datetime_obj = datetime.datetime.now()
elif isinstance(time_input, (int, float)):
self.datetime_obj = datetime.datetime.fromtimestamp(time_input)
elif isinstance(time_input, str):
self.datetime_obj = self._parse_string(time_input)
elif isinstance(time_input, datetime.datetime):
self.datetime_obj = time_input
else:
raise ValueError(f"不支持的时间输入类型: {type(time_input)}")
def _parse_string(self, time_str: str) -> datetime.datetime:
"""
解析时间字符串,支持多种格式
Args:
time_str: 时间字符串
Returns:
datetime对象
"""
# 尝试多种格式进行解析
formats = [
self.FORMAT_DATETIME, # 2023-01-01 12:00:00
self.FORMAT_DATE, # 2023-01-01
self.FORMAT_DATETIME_COMPACT, # 20230101120000
self.FORMAT_DATE_COMPACT, # 20230101
"%Y/%m/%d %H:%M:%S", # 2023/01/01 12:00:00
"%Y/%m/%d", # 2023/01/01
"%Y年%m月%d日 %H时%M分%S秒", # 2023年01月01日 12时00分00秒
"%Y年%m月%d日", # 2023年01月01日
]
for fmt in formats:
try:
return datetime.datetime.strptime(time_str, fmt)
except ValueError:
continue
# 如果所有格式都失败,尝试自动解析
try:
return datetime.datetime.fromisoformat(time_str.replace('Z', '+00:00'))
except ValueError:
raise ValueError(f"无法解析时间字符串: {time_str}")
def to_timestamp(self) -> float:
"""
转换为时间戳
Returns:
时间戳(浮点数)
"""
return self.datetime_obj.timestamp()
def to_string(self, fmt: str = FORMAT_DATETIME) -> str:
"""
转换为字符串格式
Args:
fmt: 时间格式字符串
Returns:
格式化后的时间字符串
"""
return self.datetime_obj.strftime(fmt)
def to_datetime(self) -> datetime.datetime:
"""
转换为datetime对象
Returns:
datetime对象
"""
return self.datetime_obj
def add_days(self, days: int) -> 'TimeUtils':
"""
添加天数
Args:
days: 天数(可以为负数)
Returns:
新的TimeUtils对象
"""
new_datetime = self.datetime_obj + datetime.timedelta(days=days)
return TimeUtils(new_datetime)
def add_hours(self, hours: int) -> 'TimeUtils':
"""
添加小时数
Args:
hours: 小时数(可以为负数)
Returns:
新的TimeUtils对象
"""
new_datetime = self.datetime_obj + datetime.timedelta(hours=hours)
return TimeUtils(new_datetime)
def add_minutes(self, minutes: int) -> 'TimeUtils':
"""
添加分钟数
Args:
minutes: 分钟数(可以为负数)
Returns:
新的TimeUtils对象
"""
new_datetime = self.datetime_obj + datetime.timedelta(minutes=minutes)
return TimeUtils(new_datetime)
def add_seconds(self, seconds: int) -> 'TimeUtils':
"""
添加秒数
Args:
seconds: 秒数(可以为负数)
Returns:
新的TimeUtils对象
"""
new_datetime = self.datetime_obj + datetime.timedelta(seconds=seconds)
return TimeUtils(new_datetime)
def days_ago(self, days: int) -> 'TimeUtils':
"""
计算n天前的日期时间
Args:
days: 天数
Returns:
新的TimeUtils对象
"""
return self.add_days(-days)
def days_after(self, days: int) -> 'TimeUtils':
"""
计算n天后的日期时间
Args:
days: 天数
Returns:
新的TimeUtils对象
"""
return self.add_days(days)
def start_of_day(self) -> 'TimeUtils':
"""
获取当天的开始时间(00:00:00)
Returns:
新的TimeUtils对象
"""
new_datetime = self.datetime_obj.replace(hour=0, minute=0, second=0, microsecond=0)
return TimeUtils(new_datetime)
def end_of_day(self) -> 'TimeUtils':
"""
获取当天的结束时间(23:59:59)
Returns:
新的TimeUtils对象
"""
new_datetime = self.datetime_obj.replace(hour=23, minute=59, second=59, microsecond=999999)
return TimeUtils(new_datetime)
def diff_seconds(self, other: 'TimeUtils') -> float:
"""
计算两个时间的秒数差
Args:
other: 另一个TimeUtils对象
Returns:
秒数差(self - other)
"""
return (self.datetime_obj - other.datetime_obj).total_seconds()
def diff_days(self, other: 'TimeUtils') -> float:
"""
计算两个时间的天数差
Args:
other: 另一个TimeUtils对象
Returns:
天数差(self - other)
"""
return (self.datetime_obj - other.datetime_obj).days
def is_after(self, other: 'TimeUtils') -> bool:
"""
判断是否在另一个时间之后
Args:
other: 另一个TimeUtils对象
Returns:
bool值
"""
return self.datetime_obj > other.datetime_obj
def is_before(self, other: 'TimeUtils') -> bool:
"""
判断是否在另一个时间之前
Args:
other: 另一个TimeUtils对象
Returns:
bool值
"""
return self.datetime_obj < other.datetime_obj
def is_same_day(self, other: 'TimeUtils') -> bool:
"""
判断是否在同一天
Args:
other: 另一个TimeUtils对象
Returns:
bool值
"""
return (self.datetime_obj.year == other.datetime_obj.year and
self.datetime_obj.month == other.datetime_obj.month and
self.datetime_obj.day == other.datetime_obj.day)
def format_custom(self, fmt: str) -> str:
"""
自定义格式化输出
Args:
fmt: 格式化字符串
Returns:
格式化后的字符串
"""
return self.datetime_obj.strftime(fmt)
def __str__(self) -> str:
"""字符串表示"""
return self.to_string()
def __repr__(self) -> str:
"""repr表示"""
return f"TimeUtils('{self.to_string()}')"
# 工具函数
def now() -> TimeUtils:
"""获取当前时间的TimeUtils对象"""
return TimeUtils()
def from_timestamp(timestamp: Union[int, float]) -> TimeUtils:
"""从时间戳创建TimeUtils对象"""
return TimeUtils(timestamp)
def from_string(time_str: str) -> TimeUtils:
"""从时间字符串创建TimeUtils对象"""
return TimeUtils(time_str)
def from_datetime(dt: datetime.datetime) -> TimeUtils:
"""从datetime对象创建TimeUtils对象"""
return TimeUtils(dt)
def get_timestamp() -> float:
"""获取当前时间戳"""
return time.time()
def format_timestamp(timestamp: Union[int, float], fmt: str = TimeUtils.FORMAT_DATETIME) -> str:
"""格式化时间戳"""
return TimeUtils(timestamp).to_string(fmt)
def parse_string(time_str: str) -> datetime.datetime:
"""解析时间字符串为datetime对象"""
return TimeUtils(time_str).to_datetime()
def days_ago(days: int, base_time: Optional[Union[str, int, float, datetime.datetime]] = None) -> TimeUtils:
"""计算n天前的日期时间"""
base = TimeUtils(base_time) if base_time else now()
return base.days_ago(days)
def days_after(days: int, base_time: Optional[Union[str, int, float, datetime.datetime]] = None) -> TimeUtils:
"""计算n天后的日期时间"""
base = TimeUtils(base_time) if base_time else now()
return base.days_after(days)

465
weekly_report_generator.py Normal file
View File

@@ -0,0 +1,465 @@
"""
周报生成器
基于需求文档中的周报样式,结合DeepSeek API生成客户群运营与销售工作周报
"""
import json
import re
from datetime import datetime, timedelta
from typing import Dict, List, Any, Optional
from tools import TimeUtils, DatabaseManager
from chat_session import agent
class WeeklyReportGenerator:
"""周报生成器类"""
def __init__(self, db_manager: DatabaseManager):
self.db = db_manager
self.execute_query = self.db.execute_query
def get_week_time_range(self, week_str: str) -> tuple:
"""根据周字符串获取时间范围"""
try:
# 解析week_str参数,格式为YYYY-WW
year, week_num = map(int, week_str.split('-'))
# 根据周数计算时间范围
first_day_of_year = datetime(year, 1, 1)
# 计算第一周的周一(ISO周从周一开始)
first_monday = first_day_of_year + timedelta(days=(7 - first_day_of_year.weekday()) % 7)
if first_day_of_year.weekday() <= 3: # 如果1月1日是周一到周四,则属于第一周
first_monday = first_day_of_year - timedelta(days=first_day_of_year.weekday())
# 计算指定周的周一
week_monday = first_monday + timedelta(weeks=week_num - 1)
# 计算指定周的周日
week_sunday = week_monday + timedelta(days=6)
start_date = week_monday.replace(hour=0, minute=0, second=0, microsecond=0)
end_date = week_sunday.replace(hour=23, minute=59, second=59, microsecond=999999)
start_timestamp = int(TimeUtils(start_date).to_timestamp())
end_timestamp = int(TimeUtils(end_date).to_timestamp())
return start_timestamp, end_timestamp, start_date, end_date
except Exception as e:
raise ValueError(f"周次格式错误: {week_str}, 正确格式: YYYY-WW")
def get_group_interaction_data(self, start_timestamp: int, end_timestamp: int) -> List[Dict]:
"""获取各区域群互动数据明细"""
query = '''
SELECT g.id, g.name,
COUNT(m.id) as message_count,
COUNT(DISTINCT m.sender) as active_users
FROM "subscribe" g
inner JOIN message m ON g.id = m.receiver
AND m.time >= ? AND m.time <= ?
GROUP BY g.id, g.name
ORDER BY message_count DESC
'''
result = self.execute_query(query, (f"{start_timestamp}000", f"{end_timestamp}999"))
groups_data = []
for row in result:
group_data = dict(row)
# 获取核心互动时段
peak_hours = self.get_peak_interaction_hours(group_data['id'], start_timestamp, end_timestamp)
# 计算平均每日消息量
days_in_week = 7
avg_daily_messages = group_data['message_count'] / days_in_week if days_in_week > 0 else 0
groups_data.append({
'name': group_data['name'],
'message_count': group_data['message_count'],
'active_users': group_data['active_users'],
'peak_hours': peak_hours,
'avg_daily_messages': round(avg_daily_messages, 1)
})
return groups_data
def get_peak_interaction_hours(self, group_id: str, start_timestamp: int, end_timestamp: int) -> str:
"""获取群组的核心互动时段"""
query = '''
SELECT strftime('%H', datetime(substr(time, 0, 11), 'unixepoch', '+8 hours')) as hour,
COUNT(*) as count
FROM message
WHERE receiver = ? AND time >= ? AND time <= ?
GROUP BY hour
ORDER BY count DESC
LIMIT 3
'''
result = self.execute_query(query, (group_id, f"{start_timestamp}000", f"{end_timestamp}999"))
peak_hours = []
for row in result:
hour_data = dict(row)
hour = int(hour_data['hour'])
peak_hours.append(f"{hour:02d}:00-{hour+1:02d}:00")
return ", ".join(peak_hours) if peak_hours else "无数据"
def get_hot_topics(self, start_timestamp: int, end_timestamp: int, limit: int = 5) -> List[Dict]:
"""获取本周热点话题"""
# 获取本周消息用于话题分析
query = '''
SELECT content
FROM message
WHERE time >= ? AND time <= ?
AND type IN ('text', 'mixed')
ORDER BY time DESC
LIMIT ?
'''
result = self.execute_query(query, (f"{start_timestamp}000", f"{end_timestamp}999", limit * 10))
messages = [dict(row)['content'] for row in result]
# 简单的关键词提取和话题分析
topics = self.analyze_topics(messages, limit)
return topics
def analyze_topics(self, messages: List[str], limit: int) -> List[Dict]:
"""分析消息内容,提取热点话题"""
# 常见关键词和话题
common_keywords = {
'数据对接': ['API接口', '响应速度', '数据格式', '同步', '对接'],
'合规申报': ['申报指引', '材料清单', '审核时限', '合规', '申报'],
'系统操作': ['操作步骤', '功能使用', '系统填报', '操作指南'],
'政策法规': ['政策解读', '法规要求', '法律咨询', '政策变化'],
'业务咨询': ['业务操作', '流程咨询', '业务指导', '问题解答']
}
topics = []
for topic_name, keywords in common_keywords.items():
frequency = 0
related_groups = set()
for message in messages:
if any(keyword in message for keyword in keywords):
frequency += 1
# 这里可以进一步分析涉及群聊,简化处理
related_groups.add("相关群聊")
if frequency > 0:
topics.append({
'name': topic_name,
'keywords': keywords[:3], # 取前3个关键词
'related_groups': list(related_groups)[:2], # 取前2个群聊
'frequency': frequency,
'description': f"客户关注{topic_name}相关问题的讨论"
})
# 按频率排序并限制数量
topics.sort(key=lambda x: x['frequency'], reverse=True)
return topics[:limit]
def get_mapping_messages(self, start_timestamp: int, end_timestamp: int) -> Dict:
"""获取消息ID到内容的映射"""
query = '''
SELECT id, content, time
FROM message
WHERE time >= ? AND time <= ?
'''
result = self.execute_query(query, ( f"{start_timestamp}000", f"{end_timestamp}999"))
messages = {}
for row in result:
message_data = dict(row)
messages[message_data['id']] = {'content': message_data['content'],
'timestamp': int(message_data['time'])}
return messages
def get_customer_issues(self, start_timestamp: int, end_timestamp: int) -> List[Dict]:
"""获取客户问题汇总与分类"""
# 获取QA记录中的问题
query = '''
SELECT q.groupId, q.question, q.answer, g.name as group_name
FROM qa q
INNER JOIN "subscribe" g ON q.groupId = g.id
WHERE q.start_time >= ? AND q.end_time <= ?
ORDER BY q.start_time DESC
LIMIT 50
'''
result = self.execute_query(query, (f"{start_timestamp}000", f"{end_timestamp}999"))
message_mapping = self.get_mapping_messages( start_timestamp=start_timestamp, end_timestamp=end_timestamp)
issues = []
for row in result:
qa_data = dict(row)
try:
questions = json.loads(qa_data['question'])
answers = json.loads(qa_data['answer'])
if questions:
# 提取第一个问题作为代表
question_text = [message_mapping.get(qid, {}).get('content', '').replace('\n', '<br>') for qid in questions]
question_text = '<br>'.join(question_text)
# 分类问题
category = self.classify_issue(question_text)
issues.append({
'description': question_text,
'category': category,
'frequency': 1, # 简化处理,每个问题计1次
'group': qa_data.get('group_name', '未知群聊'),
'status': '已回复' if answers and len(answers) > 0 else '提及'
})
except:
continue
return issues
def classify_issue(self, question: str) -> str:
"""分类客户问题"""
categories = {
'产品使用': ['操作', '功能', '使用', '步骤', '设置'],
'服务流程': ['流程', '服务', '响应', 'SLA', '时限'],
'需求反馈': ['需求', '建议', '改进', '优化', '增加'],
'其他': []
}
for category, keywords in categories.items():
if any(keyword in question for keyword in keywords):
return category
return '其他'
def generate_weekly_report(self, week_str: str, use_deepseek: bool = True) -> Dict[str, Any]:
"""生成周报"""
try:
# 获取时间范围
start_ts, end_ts, start_date, end_date = self.get_week_time_range(week_str)
# 获取基础统计数据
total_messages = self.get_message_count_by_time_range(start_ts, end_ts)
total_groups = self.get_group_count()
total_active_users = self.get_active_users_by_time_range(start_ts, end_ts)
# 获取详细数据
group_interaction_data = self.get_group_interaction_data(start_ts, end_ts)
hot_topics = self.get_hot_topics(start_ts, end_ts)
customer_issues = self.get_customer_issues(start_ts, end_ts)
topic_message_distribution = self.get_topic_message_distribution(start_ts, end_ts)
# 生成报告周期字符串
week_period = f"{TimeUtils(start_date).format_custom('%Y年%m月%d日')} - {TimeUtils(end_date).format_custom('%Y年%m月%d日')}"
# 构建基础周报内容
report_content = self.build_report_content(
week_period=week_period,
total_groups=total_groups,
total_messages=total_messages,
total_active_users=total_active_users,
group_interaction_data=group_interaction_data,
hot_topics=hot_topics,
customer_issues=customer_issues,
topic_message_distribution=topic_message_distribution
)
# 使用DeepSeek API增强报告内容
if use_deepseek:
enhanced_content = self.enhance_with_deepseek(report_content)
report_content += "\n" + enhanced_content
return {
'success': True,
'report': report_content,
'week': week_str,
'statistics': {
'total_messages': total_messages,
'total_groups': total_groups,
'total_active_users': total_active_users,
'group_interaction_data': group_interaction_data,
'hot_topics': hot_topics,
'customer_issues': customer_issues,
'topic_message_distribution': topic_message_distribution
}
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
def build_report_content(self, **kwargs) -> str:
"""构建周报内容"""
report = f"""# 客户群运营与销售工作周报
周期: {kwargs['week_period']}
生成时间: {datetime.now().strftime('%Y年%m月%d日')}
## 第一部分:客户群聊天记录分析与洞察
### 1. 互动概况
统计周期: 本周
汇总维度:
覆盖群聊总数: {kwargs['total_groups']} 个
总消息量: {kwargs['total_messages']} 条
总活跃人数: {kwargs['total_active_users']} 人
各区域群互动数据明细(表格):
|客户群名称(区域)|消息总量(条)|活跃人数(人)|核心互动时段|平均每日消息量|
|---|---|---|---|---|
"""
# 添加群组数据表格
for group_data in kwargs['group_interaction_data']:
report += f"|{group_data['name']}|{group_data['message_count']}|{group_data['active_users']}|{group_data['peak_hours']}|{group_data['avg_daily_messages']}|\n"
report += f"""|总计/平均值|{kwargs['total_messages']}|{kwargs['total_active_users']}|-|{round(kwargs['total_messages']/7, 1) if kwargs['total_messages'] > 0 else 0}|
### 2. 本周热点话题 TOP 5
(按讨论频次与参与度综合排序)
"""
# 添加热点话题
for i, topic in enumerate(kwargs['hot_topics'], 1):
report += f"""
话题{i}: {topic['name']}
核心关键词: {', '.join(topic['keywords'])}
涉及主要群聊: {', '.join(topic['related_groups'])}
简要描述: {topic['description']}
"""
report += f"""
### 3. 客户问题汇总与分类
(分类:产品使用 / 服务流程 / 需求反馈 / 其他)
|问题类别|具体问题描述|涉及群聊|当前状态|
|---|---|---|---|
"""
# 添加客户问题
for issue in kwargs['customer_issues']:
report += f"|{issue['category']}|{issue['description']}|{issue['group']}|{issue['status']}|\n"
report += f"""
### 4. 主题消息发送情况表
(致远速联发送的重复消息分布情况)
|消息内容|涉及群|最新发送时间|
|---|---|---|
"""
# 添加主题消息发送情况表
for topic_data in kwargs['topic_message_distribution']: # 只显示前10条
# 截断过长的消息内容
content = topic_data['content']
if len(content) > 50:
content = content[:47] + "..."
content = content.replace("\n", "<br>")
report += f"|{content}|{topic_data['group']}|{topic_data['time']}|\n"
report += f"""
### 5. 需求洞察与行动建议
(基于热点话题与问题提炼的核心需求分析)
"""
return report
def enhance_with_deepseek(self, report_content: str) -> str:
"""使用DeepSeek API增强报告内容"""
try:
prompt = """请基于以下周报数据,提供专业的洞察分析和行动建议。
要求:
1. 分析客户需求的核心趋势
2. 提供具体的行动建议
3. 格式要求:
洞察一: [基于热点话题与问题提炼的核心需求]
建议: [可落地的建议]
洞察二: [基于数据分析的发现]
建议: [具体的改进建议]
请用中文回复,保持专业和简洁。"""
enhanced_content = agent.chat_deepseek(report_content, prompt)
return enhanced_content
except Exception as e:
return f"\n## AI分析增强\n由于API调用失败,无法提供AI增强分析。错误:{str(e)}"
# 辅助方法 - 从现有代码中复制
def get_message_count_by_time_range(self, start_timestamp, end_timestamp):
"""获取指定时间范围内的消息数量"""
result = self.execute_query('''
SELECT COUNT(*) as count
FROM message
WHERE time >= ?
AND time <= ?
''', (f"{start_timestamp}000", f"{end_timestamp}999"), fetch_all=False)
return result["count"]
def get_active_users_by_time_range(self, start_timestamp, end_timestamp):
"""获取指定时间范围内的活跃用户数量"""
result = self.execute_query('''
SELECT COUNT(DISTINCT sender) as count
FROM message
WHERE time >= ?
AND time <= ?
''', (f"{start_timestamp}000", f"{end_timestamp}999"), fetch_all=False)
return result["count"]
def get_group_count(self):
"""获取群组总数"""
result = self.execute_query('SELECT COUNT(*) as count FROM "subscribe"', fetch_all=False)
return result["count"]
def get_topic_message_distribution(self, start_timestamp: int, end_timestamp: int) -> List[Dict]:
"""获取主题消息发送情况表"""
query = '''
SELECT m.content as content,
s.name as "group",
strftime('%Y-%m-%d %H:%M:%S', datetime(substr(m.time, 0, 11), 'unixepoch', '+8 hours')) as time
FROM message as m
INNER JOIN (
SELECT m.content, count(1) as count
FROM message as m
INNER JOIN subscribe as s ON m.receiver = s.id
WHERE m.sender = 'ZhiYuanSuLian'
AND length(m.content) > 20
AND m.type = 'text'
AND m.time >= ? AND m.time <= ?
GROUP BY m.content
HAVING count(1) > 1
) as p ON m.content = p.content
INNER JOIN subscribe as s ON m.receiver = s.id
ORDER BY m.content, time
'''
result = self.execute_query(query, (f"{start_timestamp}000", f"{end_timestamp}999"))
# 按消息内容分组
topics_list = []
for row in result:
row_data = dict(row)
topics_list.append(row_data)
# 按发送次数降序排序
topics_list.sort(key=lambda x: x['time'], reverse=True)
return topics_list

1949
word/stopwords.txt Normal file

File diff suppressed because it is too large Load Diff

92
word/userdict.txt Normal file
View File

@@ -0,0 +1,92 @@
抱拳 10 n
OK 10 n
破涕为笑 10 n
玫瑰 10 n
撇嘴 10 n
擦汗 10 n
捂脸 10 n
偷笑 10 n
呲牙 10 n
发呆 10 n
疑问 10 n
微笑 10 n
握手 10 n
苦涩 10 n
合十 10 n
脸红 10 n
旺柴 10 n
社会社会 10 n
2024年 3 n
2025年 3 n
1月 3 n
2月 3 n
3月 3 n
4月 3 n
5月 3 n
6月 3 n
7月 3 n
8月 3 n
9月 3 n
10月 3 n
11月 3 n
12月 3 n
1日 3 n
2日 3 n
3日 3 n
4日 3 n
5日 3 n
6日 3 n
7日 3 n
8日 3 n
9日 3 n
10日 3 n
11日 3 n
12日 3 n
13日 3 n
14日 3 n
15日 3 n
16日 3 n
17日 3 n
18日 3 n
19日 3 n
20日 3 n
21日 3 n
22日 3 n
23日 3 n
24日 3 n
25日 3 n
26日 3 n
27日 3 n
28日 3 n
29日 3 n
30日 3 n
31日 3 n
信披 3 n
监管员 3 n
新系统 5 n
新模板 3 n
新变化 3 n
系统更新 3 n
定期报告 3 n
回复消息 3 n
业务专区 3 n
临时公告 3 n
填报系统 3 n
信披顾问 3 n
公告编制系统 3 n
张瑞婷 3 nr
安文辉 3 nr
闻举 3 nr
小潘 3 nr
胡蓉 3 nr
刘秋萍 3 nr
婷婷 3 nr
李婷婷 3 nr
致远速联 3 nt
安文辉 3 n

59
word/word_cloud.py Normal file
View File

@@ -0,0 +1,59 @@
import jieba
import jieba.posseg as pseg
import pandas as pd
from wordcloud import WordCloud
import matplotlib.pyplot as plt
from ..file import LocalFileManager
from ..chat_session import ChatSessionManager
chat_session = ChatSessionManager()
data = chat_session.get_chat_history("2025-10-11 00:00:00","2025-10-18 23:59:59")
lfm = LocalFileManager("../chat_history")
# 加载用户词典(使用正确的路径)
jieba.load_userdict("../chat_history/userdict.txt")
# 获取停用词
skip_words_content = lfm.get_file("stopwords.txt").decode('utf-8')
skip_words = skip_words_content.splitlines() + [" ", '\n']
# 生成词频字典(需提前完成分词统计)
if __name__ == '__main__':
words = {}
for i in data:
for m in pseg.cut(i[1]):
word = m.word
flag = m.flag
# 正确的比较逻辑:使用word而不是m对象
if word not in skip_words and flag not in ["a", "b", "c", "d", "e", "f","r","nrt"] and len(word) > 1:
if flag == "eng" and len(word) >= 24:
print("skip")
else:
if word not in words:
words[word] = (1,flag)
else:
words[word] = (words[word][0] + 1,flag)
# print("分词结果统计:")
# for word, count in sorted(words.items(), key=lambda x: x[1], reverse=True):
# print(f"{word}: {count}")
df = pd.DataFrame([[word,count[0],count[1]] for word,count in words.items()],columns=["word","count","flag"])
df.to_csv("../chat_history/wordcloud.csv",index=False)
word_freq = {word:count[0] for word,count in words.items()}
# 创建词云对象
wc = WordCloud(
font_path='msyh.ttc', # 微软雅黑字体文件路径
width=800,
height=600,
background_color='white',
max_words=100
)
# 生成词云
wc.generate_from_frequencies(word_freq)
# 显示图形
plt.figure(figsize=(10, 6))
plt.imshow(wc, interpolation='bilinear')
plt.axis('off')
plt.savefig("../chat_history/wordcloud.png")

BIN
word/wordcloud.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB