更新数据截止日2026.01.08
This commit is contained in:
49
api/__init__.py
Normal file
49
api/__init__.py
Normal 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
40
api/db.py
Normal 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
645
api/lib.py
Normal 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
677
api/v1.py
Normal 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
641
api/v2.py
Normal 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
276
api/v3.py
Normal 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
18
app.py
Normal 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
735
chat_session.py
Normal 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
196
file.py
Normal 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
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
mammoth
|
||||
pytz
|
||||
flask
|
||||
requests
|
||||
151
script.py
Normal file
151
script.py
Normal 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
358
static/css/daily_report.css
Normal 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
471
static/css/index.css
Normal 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
274
static/css/markdown.css
Normal 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
848
static/css/qa.css
Normal 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);
|
||||
}
|
||||
}
|
||||
534
static/css/weekly_report.css
Normal file
534
static/css/weekly_report.css
Normal 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
148
static/js/daily_report.js
Normal 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
182
static/js/h2p.js
Normal 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
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
303
static/js/index.js
Normal 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';
|
||||
}
|
||||
}
|
||||
311
static/js/markdown-parser.js
Normal file
311
static/js/markdown-parser.js
Normal 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>');
|
||||
|
||||
// 处理普通图片格式 
|
||||
html = html.replace(this.patterns.image, '<img width="100%" src="$2" alt="$1">');
|
||||
|
||||
// 处理base64图片格式 
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
// 然后恢复<br>标签的功能
|
||||
return escapedText
|
||||
.replace(/<br\s*\/?>/gi, '<br>')
|
||||
.replace(/<br\s*\/?>/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;
|
||||
}
|
||||
273
static/js/markdown-plugin-table.js
Normal file
273
static/js/markdown-plugin-table.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
// 然后恢复<br>标签的功能
|
||||
return escapedText
|
||||
.replace(/<br\s*\/?>/gi, '<br>')
|
||||
.replace(/<br\s*\/?>/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
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
191
static/js/weekly_report.js
Normal 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);
|
||||
}
|
||||
}
|
||||
284
static/js/weekly_report_v3.js
Normal file
284
static/js/weekly_report_v3.js
Normal 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>');
|
||||
}
|
||||
60
templates/daily_report.html
Normal file
60
templates/daily_report.html
Normal 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
92
templates/index.html
Normal 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()">×</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
516
templates/markdown.html
Normal file
File diff suppressed because one or more lines are too long
156
templates/qa.html
Normal file
156
templates/qa.html
Normal 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()">×</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()">×</span>
|
||||
</div>
|
||||
|
||||
<div id="previewContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/qa.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
63
templates/weekly_report.html
Normal file
63
templates/weekly_report.html
Normal 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>
|
||||
93
templates/weekly_report_v3.html
Normal file
93
templates/weekly_report_v3.html
Normal 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
398
tools.py
Normal 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
465
weekly_report_generator.py
Normal 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
1949
word/stopwords.txt
Normal file
File diff suppressed because it is too large
Load Diff
92
word/userdict.txt
Normal file
92
word/userdict.txt
Normal 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
59
word/word_cloud.py
Normal 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
BIN
word/wordcloud.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 232 KiB |
Reference in New Issue
Block a user