共计 33327 个字符,预计需要花费 84 分钟才能阅读完成。
导入
对于 Markdown,或像是 Hydro 的 主页 等可以使用 富文本 等来书写一些 介绍 文字但没有 讨论 等互动系统的,要是我们也想实现 讨论和点赞 功能怎么办?看似很难的要求,实则 解决方案十分简单。
解决方案
具体思路是这样的:由于富文本支持 超链接 和动态获取网络图片 ,我们只需要准备一台 服务器 ,随后在上面部署好一套 后端,实现两个功能:
- 支持生成和返回svg 图片
- 支持 点赞
如此一来,想要在 Markdown 里使用这个点赞的系统,只需要这样:
[](http:// 你的服务器 / 点赞地址)
已有  人为我点赞
Markdown
相关代码
Python 服务器 部分代码(来自Deepseek):
# ----------------------------------------- 点赞 Markdown -----------------------------------------
# 文件路径
COUNTER_FILE = Path("zans.txt")
def load_likes():
"""从文件加载点赞数"""
if not COUNTER_FILE.exists():
return {}
likes = {}
try:
with open(COUNTER_FILE, 'r', encoding='utf-8') as f:
for line in f:
if line.strip():
parts = line.strip().split(':', 1)
if len(parts) == 2:
article_id = parts[0].strip()
try:
likes[article_id] = int(parts[1].strip())
except ValueError:
likes[article_id] = 0
except Exception as e:
print(f"Error loading likes: {e}")
likes = {}
return likes
def save_likes(likes_dict):
"""保存点赞数到文件"""
try:
# 先备份原文件
if COUNTER_FILE.exists():
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_file = COUNTER_FILE.parent / f"zans_backup_{timestamp}.txt"
COUNTER_FILE.rename(backup_file)
# 写入新文件
with open(COUNTER_FILE, 'w', encoding='utf-8') as f:
for article_id, count in likes_dict.items():
f.write(f"{article_id}:{count}\n")
# 删除旧备份(只保留最新的 5 个)
backup_files = sorted(COUNTER_FILE.parent.glob("zans_backup_*.txt"))
for old_backup in backup_files[:-5]:
old_backup.unlink()
return True
except Exception as e:
print(f"Error saving likes: {e}")
return False
def increment_like(article_id):
"""增加指定文章的点赞数"""
likes = load_likes()
current_count = likes.get(article_id, 0)
likes[article_id] = current_count + 1
return save_likes(likes)
def get_like_count(article_id):
"""获取指定文章的点赞数"""
likes = load_likes()
return likes.get(article_id, 0)
@app.route('/like/<path:article_id>', methods=['GET'])
def handle_like(article_id):
"""
处理点赞请求
增加点赞数后重定向回文章页面
"""
# 获取来源页面(如果提供了 referrer)
referrer = request.referrer or f"/article/{article_id}"
ref = request.args.get("redirect")
if ref != None:
referrer = ref
# 增加点赞数
increment_like(article_id)
# 重定向回来源页面
return redirect(referrer)
@app.route('/like/counter/<path:article_id>.svg', methods=['GET'])
def like_counter_svg(article_id):
"""
生成点赞数 SVG 图片
只返回 SVG 格式的图片
"""
# 获取点赞数
count = get_like_count(article_id)
# 获取自定义参数(可选)
color = request.args.get('color', '000000') # 文字颜色
bgcolor = request.args.get('bgcolor', 'ffffff') # 背景颜色
size = int(request.args.get('size', 14)) # 字体大小
# 生成 SVG
svg_content = f'''<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="{len(str(count)) * size * 0.6 + 40}" height="{size + 10}" viewBox="0 0 {len(str(count)) * size * 0.6 + 40} {size + 10}">
<!-- 背景(可选)-->
<rect x="0" y="0" width="100%" height="100%" fill="#{bgcolor}" rx="3" ry="3"/>
<!-- 点赞图标 -->
<text x="15" y="{size + 3}" font-family="Arial, sans-serif" font-size="{size}" fill="#{color}" style="font-weight: bold;">
♥
</text>
<!-- 点赞数 -->
<text x="35" y="{size + 3}" font-family="Arial, sans-serif" font-size="{size}" fill="#{color}">
{count}
</text>
</svg>'''
# 返回 SVG 图片
return Response(
svg_content,
mimetype='image/svg+xml',
headers={
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
)
@app.route('/like/counter/<path:article_id>', methods=['GET'])
def like_counter_redirect(article_id):
"""
点赞计数器的重定向端点(兼容旧 URL)
重定向到 SVG 版本
"""
return redirect(f'/like/counter/{article_id}.svg?{request.query_string}')
@app.route('/like/status/<path:article_id>', methods=['GET'])
def like_status(article_id):
"""
返回点赞状态的纯文本信息(用于调试)
"""
count = get_like_count(article_id)
return f" 文章 {article_id} 的点赞数: {count}"
@app.route('/like/admin/reset/<path:article_id>', methods=['GET'])
def reset_likes(article_id):
"""
重置指定文章的点赞数(管理员功能)
"""
likes = load_likes()
likes[article_id] = 0
save_likes(likes)
return redirect(f'/like/status/{article_id}')
@app.route('/like/admin/list', methods=['GET'])
def list_all_likes():
"""
列出所有文章的点赞数(管理员功能)
"""
likes = load_likes()
if not likes:
return "暂无点赞数据"
result = ["所有文章的点赞数:", "=" * 30]
for article_id, count in sorted(likes.items()):
result.append(f"{article_id}: {count}")
return "\n".join(result)
"""
评论系统修复版
修复了重定向问题和文件保存问题
"""
# ==================== 评论系统常量 ====================
COMMENTS_FILE = Path("comments.txt")
ADMIN_PASSWORD_FILE = Path("admin_pwd.txt")
# ==================== 评论数据管理 ====================
def load_comments():
"""从文件加载评论数据"""
comments = {}
if not COMMENTS_FILE.exists():
return comments
try:
with open(COMMENTS_FILE, 'r', encoding='utf-8') as f:
current_article = None
for line in f:
line = line.strip()
if not line:
continue
if line.startswith("[") and line.endswith("]"):
# 文章 ID 行
article_id = line[1:-1]
comments[article_id] = []
current_article = article_id
elif line.startswith("#") and current_article:
# 评论数据行
try:
parts = line[1:].split("|", 3)
if len(parts) == 4:
comment_id, username, content, timestamp = parts
comment = {
"id": comment_id,
"username": username,
"content": content,
"timestamp": timestamp,
"visible": True
}
comments[current_article].append(comment)
except ValueError:
continue
except Exception as e:
print(f"Error loading comments: {e}")
import traceback
traceback.print_exc()
return comments
def save_comments(comments_dict):
"""保存评论数据到文件"""
try:
# 创建备份(如果原文件存在)
if COMMENTS_FILE.exists():
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_file = COMMENTS_FILE.parent / f"comments_backup_{timestamp}.txt"
import shutil
shutil.copy2(COMMENTS_FILE, backup_file)
# 清理旧备份(保留最新的 3 个)
backup_files = sorted(COMMENTS_FILE.parent.glob("comments_backup_*.txt"))
for old_backup in backup_files[:-3]:
try:
old_backup.unlink()
except:
pass
# 写入新文件
with open(COMMENTS_FILE, 'w', encoding='utf-8') as f:
for article_id, comment_list in comments_dict.items():
if comment_list: # 只写入有评论的文章
f.write(f"[{article_id}]\n")
for comment in comment_list:
if comment.get("visible", True):
f.write(f"#{comment['id']}|{comment['username']}|{comment['content']}|{comment['timestamp']}\n")
f.write("\n")
return True
except Exception as e:
print(f"Error saving comments: {e}")
import traceback
traceback.print_exc()
return False
def add_comment(article_id, username, content):
"""添加新评论"""
comments = load_comments()
# 如果文章没有评论记录,创建空列表
if article_id not in comments:
comments[article_id] = []
# 生成评论 ID(基于时间和随机数)
comment_id = f"c{datetime.now().strftime('%Y%m%d%H%M%S')}_{secrets.token_hex(3)}"
comment = {
"id": comment_id,
"username": username.strip() or "匿名用户",
"content": content.strip(),
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"visible": True
}
comments[article_id].append(comment)
success = save_comments(comments)
return comment_id if success else None
def get_comments_count(article_id):
"""获取评论数量"""
comments = load_comments()
if article_id not in comments:
return 0
return len([c for c in comments[article_id] if c.get("visible", True)])
def parse_markdown_text(text):
"""
解析 Markdown 格式的文本,返回带有样式信息的片段列表
支持:
- 粗体:** 文本 ** 或 __文本__
- 斜体:* 文本 * 或 _文本_
- 高亮:== 文本 ==
"""
import re
# 定义 Markdown 模式
patterns = [
(r'\*\*(.*?)\*\*', 'bold'), # 粗体:**text**
(r'__(.*?)__', 'bold'), # 粗体:__text__
(r'\*(.*?)\*', 'italic'), # 斜体:*text*
(r'_(.*?)_', 'italic'), # 斜体:_text_
(r'==(.*?)==', 'highlight') # 高亮:==text==
]
# 解析文本
fragments = []
pos = 0
text_len = len(text)
while pos < text_len:
# 查找下一个 Markdown 标记
earliest_match = None
earliest_pos = text_len
for pattern, style_type in patterns:
match = re.search(pattern, text[pos:])
if match:
match_start = pos + match.start()
if match_start < earliest_pos:
earliest_pos = match_start
earliest_match = (pattern, style_type, match)
if earliest_match and earliest_pos < text_len:
# 添加普通文本(如果有)
if earliest_pos > pos:
plain_text = text[pos:earliest_pos]
fragments.append({
'text': plain_text,
'style': 'normal'
})
# 添加样式文本
pattern, style_type, match = earliest_match
styled_text = match.group(1)
fragments.append({
'text': styled_text,
'style': style_type
})
# 更新位置
pos = earliest_pos + len(match.group(0))
else:
# 添加剩余的普通文本
if pos < text_len:
plain_text = text[pos:]
fragments.append({
'text': plain_text,
'style': 'normal'
})
break
# 如果没有样式片段,返回单个普通片段
if not fragments:
fragments.append({
'text': text,
'style': 'normal'
})
return fragments
def calculate_text_width(text, font_size=12):
"""
估算文本宽度(近似值)
英文字符:0.6 * 字体大小
中文字符:1.0 * 字体大小
"""
width = 0
for char in text:
if '\u4e00' <= char <= '\u9fff': # 中文字符
width += font_size
else: # 英文字符
width += font_size * 0.6
return width
def generate_comments_svg(comments, limit=None):
"""生成带用户头像和 Markdown 格式支持的评论列表 SVG"""
if not comments:
return generate_no_comments_svg()
# 限制显示条数
if limit and len(comments) > limit:
display_comments = comments[:limit]
show_more = True
else:
display_comments = comments
show_more = False
# 预定义一些好看的背景颜色
avatar_colors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FECA57',
'#FF9FF3', '#54A0FF', '#5F27CD', '#00D2D3', '#FF9F43',
'#1DD1A1', '#FF9F43', '#2ECC71', '#9B59B6', '#3498DB'
]
# 定义 Markdown 样式
markdown_styles = {
'bold': {
'font_weight': 'bold',
'font_size': 12,
'fill': '#212529'
},
'italic': {
'font_style': 'italic',
'font_size': 12,
'fill': '#212529'
},
'highlight': {
'font_size': 12,
'fill': '#212529'
},
'normal': {
'font_size': 12,
'fill': '#212529'
}
}
# 计算 SVG 高度
header_height = 40
footer_height = 30 if show_more else 10
# 动态计算每行评论的高度
line_heights = []
line_contents = [] # 存储每行评论的解析内容
for comment in display_comments:
content = comment['content']
# 解析 Markdown
fragments = parse_markdown_text(content)
# 计算内容行数
line_count = 1
current_line_width = 0
max_line_width = 320 # 总宽度 400 - 左边距 50 - 右边距 30
for fragment in fragments:
fragment_width = calculate_text_width(fragment['text'], 12)
if current_line_width + fragment_width > max_line_width:
line_count += 1
current_line_width = fragment_width
else:
current_line_width += fragment_width
# 基本行高 = 头像高度 + 用户名和内容间距
line_height = 50 + (line_count * 20)
line_heights.append(line_height)
line_contents.append(fragments)
total_height = header_height + sum(line_heights) + footer_height
svg_lines = [f'''<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="{total_height}" viewBox="0 0 400 {total_height}">
<!-- 背景 -->
<rect width="100%" height="100%" fill="#f8f9fa" rx="5" ry="5"/>
<!-- 标题栏 -->
<rect x="0" y="0" width="100%" height="30" fill="#007bff" rx="5" ry="5"/>
<text x="10" y="20" font-family="Arial, sans-serif" font-size="14" fill="white" font-weight="bold">
✎ 评论 ({len(comments)}条)
</text>''']
# 当前 Y 坐标
current_y = 40
# 添加评论
for i, comment in enumerate(display_comments):
# 选择头像颜色(基于用户名哈希)
import hashlib
if comment['username']:
color_index = int(hashlib.md5(comment['username'].encode()).hexdigest()[:8], 16) % len(avatar_colors)
else:
color_index = 0
avatar_color = avatar_colors[color_index]
# 获取用户首字母(用于头像)
if comment['username']:
first_char = comment['username'][0].upper()
else:
first_char = "?"
# 头像圆
svg_lines.append(f'''
<!-- 评论 {i+1} 的头像 -->
<circle cx="25" cy="{current_y + 15}" r="18" fill="{avatar_color}"/>
<text x="25" y="{current_y + 21}" font-family="Arial, sans-serif" font-size="14" fill="white"
text-anchor="middle" font-weight="bold">
{first_char}
</text>''')
# 用户名和时间
svg_lines.append(f'''
<!-- 评论 {i+1} 的用户信息 -->
<text x="50" y="{current_y + 10}" font-family="Arial, sans-serif" font-size="13" fill="#495057" font-weight="bold">
{comment['username']}
</text>
<text x="50" y="{current_y + 25}" font-family="Arial, sans-serif" font-size="10" fill="#6c757d">
{comment['timestamp']}
</text>''')
# 评论内容(支持 Markdown)
content_start_x = 50
content_start_y = current_y + 40
fragments = line_contents[i]
# 分页绘制文本
line_index = 0
current_x = content_start_x
current_y_line = content_start_y
max_line_width = 320
# 绘制高亮背景(先绘制所有高亮背景)
highlight_rectangles = []
temp_x = content_start_x
temp_y_line = content_start_y
for fragment in fragments:
if fragment['style'] == 'highlight':
fragment_width = calculate_text_width(fragment['text'], 12)
# 检查是否需要换行
if temp_x + fragment_width > max_line_width:
temp_y_line += 20
temp_x = content_start_x
highlight_rectangles.append({
'x': temp_x,
'y': temp_y_line - 10,
'width': fragment_width + 4,
'height': 16
})
temp_x += fragment_width
else:
fragment_width = calculate_text_width(fragment['text'], 12)
if temp_x + fragment_width > max_line_width:
temp_y_line += 20
temp_x = content_start_x
temp_x += fragment_width
# 绘制高亮矩形
for rect in highlight_rectangles:
svg_lines.append(f'''
<rect x="{rect['x'] - 2}" y="{rect['y']}" width="{rect['width']}" height="{rect['height']}"
fill="#FFEB3B" opacity="0.5" rx="2" ry="2"/>''')
# 绘制文本片段
for fragment in fragments:
style_type = fragment['style']
text = fragment['text']
fragment_width = calculate_text_width(text, 12)
# 检查是否需要换行
if current_x + fragment_width > max_line_width:
line_index += 1
current_y_line += 20
current_x = content_start_x
# 构建样式属性
style_attrs = []
style_config = markdown_styles[style_type]
if style_type == 'bold':
style_attrs.append('font-weight="bold"')
elif style_type == 'italic':
style_attrs.append('font-style="italic"')
# 设置字体大小和颜色
style_attrs.append(f'font-size="{style_config["font_size"]}"')
style_attrs.append(f'fill="{style_config["fill"]}"')
# 合并样式属性
style_str = " ".join(style_attrs)
# 绘制文本片段
svg_lines.append(f'''
<text x="{current_x}" y="{current_y_line}" font-family="Arial, sans-serif" {style_str}>
{text}
</text>''')
current_x += fragment_width
# 分隔线
if i < len(display_comments) - 1:
svg_lines.append(f'''
<line x1="10" y1="{current_y + line_heights[i] - 5}" x2="390" y2="{current_y + line_heights[i] - 5}"
stroke="#dee2e6" stroke-width="1"/>''')
current_y += line_heights[i]
# 显示更多提示
if show_more:
svg_lines.append(f'''
<text x="50%" y="{total_height - 10}" text-anchor="middle" font-family="Arial, sans-serif"
font-size="12" fill="#007bff">
↓ 查看更多评论(还有 {len(comments) - limit} 条)→
</text>''')
# 添加点击区域
if show_more:
svg_lines.append(f'''
<rect x="0" y="{total_height - 25}" width="400" height="25" fill="transparent" opacity="0">
<title> 点击查看所有评论 </title>
</rect>''')
svg_lines.append("</svg>")
return "\n".join(svg_lines)
def generate_no_comments_svg():
"""生成无评论的 SVG"""
svg = '''<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="150">
<rect width="100%" height="100%" fill="#f8f9fa" rx="5" ry="5"/>
<!-- 头像 -->
<circle cx="200" cy="50" r="25" fill="#dee2e6"/>
<text x="200" y="55" text-anchor="middle" font-family="Arial, sans-serif"
font-size="20" fill="#adb5bd" font-weight="bold">
?
</text>
<!-- 提示信息 -->
<text x="200" y="100" text-anchor="middle" font-family="Arial, sans-serif"
font-size="16" fill="#6c757d" font-weight="bold">
暂无评论
</text>
<text x="200" y="120" text-anchor="middle" font-family="Arial, sans-serif"
font-size="12" fill="#adb5bd">
点击发表第一条评论
</text>
<!-- 示例 Markdown 提示 -->
<text x="200" y="135" text-anchor="middle" font-family="Arial, sans-serif"
font-size="10" fill="#6c757d" opacity="0.7">
支持: ** 粗体 ** * 斜体 * == 高亮 ==
</text>
<!-- 点击区域 -->
<rect x="0" y="0" width="400" height="150" fill="transparent" opacity="0">
<title> 点击发表评论 </title>
</rect>
</svg>'''
return svg
# ==================== Flask 路由 ====================
@app.route('/comment/<path:article_id>')
def comment_form(article_id):
"""显示评论表单页面"""
redirect_url = request.args.get('redirect', '/')
return f'''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title> 发表评论 - {article_id}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
max-width: 600px; margin: 0 auto; padding: 20px; background: #f5f5f5; }}
.form-container {{ background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }}
h1 {{ color: #333; margin-bottom: 20px; }}
.form-group {{ margin-bottom: 20px; }}
label {{ display: block; margin-bottom: 8px; font-weight: 600; color: #555; }}
input, textarea {{
width: 100%;
padding: 12px;
border: 2px solid #ddd;
border-radius: 6px;
font-size: 16px;
transition: border-color 0.3s;
}}
input:focus, textarea:focus {{
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0,123,255,0.1);
}}
button {{
background: linear-gradient(135deg, #007bff, #0056b3);
color: white;
padding: 14px 28px;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
width: 100%;
}}
button:hover {{
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0,123,255,0.3);
}}
.back-link {{
display: inline-block;
margin-top: 20px;
color: #666;
text-decoration: none;
padding: 10px;
border-radius: 5px;
transition: background 0.3s;
}}
.back-link:hover {{ background: #f0f0f0; }}
.info-box {{
background: #e7f3ff;
border-left: 4px solid #007bff;
padding: 15px;
margin-bottom: 20px;
border-radius: 0 5px 5px 0;
}}
.char-count {{
text-align: right;
font-size: 12px;
color: #666;
margin-top: 5px;
}}
</style>
<script>
function updateCharCount() {{
var textarea = document.getElementById('content');
var count = document.getElementById('charCount');
count.textContent = textarea.value.length + '/500';
if (textarea.value.length > 500) {{
count.style.color = '#dc3545';
}} else {{
count.style.color = '#666';
}}
}}
</script>
</head>
<body>
<div class="form-container">
<h1>✎ 发表评论 </h1>
<div class="info-box">
<p><strong> 文章:</strong>{article_id}</p>
<p><strong> 注意:</strong> 请文明发言,尊重他人观点 </p>
</div>
<form action="/comment/{article_id}/submit?redirect={redirect_url}" method="post">
<input type="hidden" name="article_id" value="{article_id}">
<div class="form-group">
<label for="username">👤 昵称(可选):</label>
<input type="text" id="username" name="username" placeholder=" 匿名用户 ">
</div>
<div class="form-group">
<label for="content">💭 评论内容:</label>
<textarea id="content" name="content" rows="6"
placeholder=" 请输入您的评论..."
maxlength="500"
oninput="updateCharCount()"
required></textarea>
<div class="char-count" id="charCount">0/500</div>
</div>
<button type="submit">📤 提交评论 </button>
</form>
<a href="{redirect_url}" class="back-link">← 返回文章 </a>
</div>
</body>
</html>'''
@app.route('/comment/<path:article_id>/submit', methods=['POST'])
def submit_comment(article_id):
"""提交评论"""
username = request.form.get('username', '').strip()
content = request.form.get('content', '').strip()
redirect_url = request.args.get('redirect', '/')
if not content:
# 如果内容为空,返回错误页面
return '''
<!DOCTYPE html>
<html>
<head><title> 评论提交失败 </title></head>
<body>
<h1> 评论提交失败 </h1>
<p> 评论内容不能为空。</p>
<a href="javascript:history.back()"> 返回修改 </a>
</body>
</html>''', 400
# 限制内容长度
if len(content) > 500:
content = content[:500]
# 添加评论
comment_id = add_comment(article_id, username, content)
if comment_id:
# 重定向回指定页面
return redirect(redirect_url)
else:
return '''
<!DOCTYPE html>
<html>
<head><title> 评论提交失败 </title></head>
<body>
<h1> 评论提交失败 </h1>
<p> 保存评论时出现错误,请稍后重试。</p>
<a href="javascript:history.back()"> 返回修改 </a>
</body>
</html>''', 500
@app.route('/comment/<path:article_id>/preview.svg')
def comment_preview_svg(article_id):
"""生成评论预览 SVG(显示最新 5 条评论)"""
comments = load_comments()
article_comments = comments.get(article_id, [])
# 过滤可见评论并按时间倒序排列
visible_comments = [c for c in article_comments if c.get("visible", True)]
visible_comments.sort(key=lambda x: x['timestamp'], reverse=True)
svg_content = generate_comments_svg(visible_comments, limit=5)
return Response(
svg_content,
mimetype='image/svg+xml',
headers={
'Cache-Control': 'max-age=60', # 缓存 60 秒
'Pragma': 'public',
}
)
@app.route('/comment/<path:article_id>/list.svg')
def comment_list_svg(article_id):
"""生成完整评论列表 SVG"""
comments = load_comments()
article_comments = comments.get(article_id, [])
# 过滤可见评论并按时间倒序排列
visible_comments = [c for c in article_comments if c.get("visible", True)]
visible_comments.sort(key=lambda x: x['timestamp'], reverse=True)
svg_content = generate_comments_svg(visible_comments)
return Response(
svg_content,
mimetype='image/svg+xml',
headers={
'Cache-Control': 'max-age=60',
'Pragma': 'public',
}
)
@app.route('/comment/<path:article_id>/list')
def comment_list_html(article_id):
"""显示 HTML 格式的评论列表"""
comments = load_comments()
article_comments = comments.get(article_id, [])
# 过滤可见评论并按时间倒序排列
visible_comments = [c for c in article_comments if c.get("visible", True)]
visible_comments.sort(key=lambda x: x['timestamp'], reverse=True)
html = f'''<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title> 评论列表 - {article_id}</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
line-height: 1.6;
}}
.container {{
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 15px rgba(0,0,0,0.1);
}}
h1 {{
color: #333;
border-bottom: 3px solid #007bff;
padding-bottom: 10px;
margin-bottom: 20px;
}}
.stats {{
background: #e7f3ff;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}}
.comment {{
border-bottom: 1px solid #eee;
padding: 20px 0;
animation: fadeIn 0.3s ease-in;
}}
@keyframes fadeIn {{
from {{ opacity: 0; transform: translateY(10px); }}
to {{ opacity: 1; transform: translateY(0); }}
}}
.comment-header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}}
.user-info {{ display: flex; align-items: center; }}
.avatar {{
width: 40px;
height: 40px;
background: linear-gradient(135deg, #007bff, #00d4ff);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
margin-right: 10px;
}}
.username {{
font-weight: bold;
color: #333;
font-size: 16px;
}}
.timestamp {{
color: #666;
font-size: 14px;
}}
.content {{
margin-top: 10px;
color: #444;
font-size: 16px;
white-space: pre-wrap;
word-wrap: break-word;
}}
.empty-state {{
text-align: center;
padding: 60px 20px;
color: #666;
}}
.actions {{
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #eee;
}}
.btn {{
display: inline-block;
padding: 12px 24px;
margin-right: 10px;
background: #007bff;
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 600;
transition: all 0.3s;
}}
.btn:hover {{
background: #0056b3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,123,255,0.3);
}}
.btn-secondary {{ background: #6c757d; }}
.btn-secondary:hover {{ background: #545b62; }}
.pagination {{
text-align: center;
margin-top: 30px;
}}
.page-btn {{
display: inline-block;
padding: 8px 16px;
margin: 0 5px;
border: 1px solid #ddd;
text-decoration: none;
color: #007bff;
border-radius: 4px;
}}
.page-btn.active {{
background: #007bff;
color: white;
border-color: #007bff;
}}
</style>
</head>
<body>
<div class="container">
<h1>✎ 评论列表 </h1>
<div class="stats">
<div>
<strong> 文章:</strong>{article_id}
</div>
<div>
<strong> 评论总数:</strong>{len(visible_comments)} 条
</div>
</div>
<div class="actions">
<a href="/comment/{article_id}?redirect={request.args.get("redirect") or request.referrer or '/'}" class="btn">✏️ 发表评论 </a>
<a href="{request.args.get("redirect") or request.referrer or '/'}" class="btn btn-secondary">← 返回文章 </a>
</div>
<hr style="margin: 30px 0;">'''
if not visible_comments:
html += '''
<div class="empty-state">
<h3 style="color: #999; margin-bottom: 10px;">✎ 暂无评论 </h3>
<p style="color: #777;"> 成为第一个评论的人吧!</p>
</div>'''
else:
for comment in visible_comments:
# 获取用户首字母作为头像
first_char = comment['username'][0].upper() if comment['username'] else '?'
html += f'''
<div class="comment">
<div class="comment-header">
<div class="user-info">
<div class="avatar">{first_char}</div>
<div>
<div class="username">{comment['username']}</div>
<div class="timestamp">⏰ {comment['timestamp']}</div>
</div>
</div>
<div style="font-size: 12px; color: #888;">
ID: {comment['id']}
</div>
</div>
<div class="content">
{comment['content']}
</div>
</div>'''
html += '''
<div class="actions">
<p style="text-align: center; color: #666;">
SVG 评论预览:<a href="/comment/{}/preview.svg" target="_blank"> 点击查看 </a>
</p>
</div>
</div>
</body>
</html>'''.format(article_id)
return html
# ==================== 管理员面板 ====================
def init_admin_password():
"""初始化管理员密码"""
if not ADMIN_PASSWORD_FILE.exists():
default_password = secrets.token_hex(8) # 生成随机密码
with open(ADMIN_PASSWORD_FILE, 'w', encoding='utf-8') as f:
f.write(default_password)
print(f" 初始管理员密码: {default_password}")
print("请保存此密码,然后访问 /comment/admin/login 登录")
def check_admin_session():
"""检查管理员会话"""
token = request.args.get('token') or request.form.get('token')
if not token:
return False
# 简单的会话检查(生产环境应使用更安全的方式)
session_file = Path(f"admin_session_{token}.txt")
if session_file.exists():
# 检查会话是否过期(24 小时)
import time
if time.time() - session_file.stat().st_mtime < 86400:
return True
return False
@app.route('/comment/admin/login')
def admin_login_page():
"""管理员登录页面"""
return '''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title> 评论管理登录 </title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-box {
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 15px 35px rgba(0,0,0,0.2);
width: 100%;
max-width: 400px;
}
h2 {
color: #333;
margin-bottom: 30px;
text-align: center;
}
.input-group {
margin-bottom: 20px;
}
input {
width: 100%;
padding: 15px;
border: 2px solid #ddd;
border-radius: 6px;
font-size: 16px;
transition: border-color 0.3s;
}
input:focus {
outline: none;
border-color: #667eea;
}
button {
width: 100%;
padding: 15px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s;
}
button:hover {
transform: translateY(-2px);
}
.error {
color: #dc3545;
margin-top: 10px;
text-align: center;
}
.logo {
text-align: center;
font-size: 48px;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="login-box">
<div class="logo">🔐</div>
<h2> 评论管理登录 </h2>
<form action="/comment/admin/verify" method="post">
<div class="input-group">
<input type="password" name="password" placeholder=" 管理员密码 " required>
</div>
<button type="submit"> 登录管理面板 </button>
</form>
</div>
</body>
</html>'''
@app.route('/comment/admin/verify', methods=['POST'])
def admin_verify():
"""验证管理员密码"""
password = request.form.get('password', '')
# 读取密码文件
if ADMIN_PASSWORD_FILE.exists():
with open(ADMIN_PASSWORD_FILE, 'r', encoding='utf-8') as f:
correct_password = f.read().strip()
if password == correct_password:
# 生成管理会话令牌
session_token = secrets.token_hex(16)
session_file = Path(f"admin_session_{session_token}.txt")
session_file.touch()
return redirect(f'/comment/admin?token={session_token}')
return '''
<!DOCTYPE html>
<html>
<head><title> 登录失败 </title></head>
<body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
<h1 style="color: #dc3545;">❌ 密码错误 </h1>
<p> 请检查密码后重试。</p>
<a href="/comment/admin/login" style="color: #007bff;"> 返回登录 </a>
</body>
</html>''', 401
@app.route('/comment/admin')
def admin_panel():
"""评论管理面板"""
if not check_admin_session():
return redirect('/comment/admin/login')
token = request.args.get('token', '')
comments = load_comments()
total_comments = sum(len(comments[aid]) for aid in comments)
visible_comments = sum(
len([c for c in comments[aid] if c.get("visible", True)])
for aid in comments
)
html = f'''<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title> 评论管理面板 </title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
margin: 0;
background: #f5f5f5;
}}
.header {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}}
.container {{
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}}
.stats-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}}
.stat-card {{
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}}
.stat-card h3 {{ margin-top: 0; color: #666; }}
.stat-number {{
font-size: 36px;
font-weight: bold;
color: #667eea;
margin: 10px 0;
}}
.article-list {{
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
margin-top: 20px;
}}
.article-item {{
border-bottom: 1px solid #eee;
padding: 15px 0;
transition: background 0.3s;
}}
.article-item:hover {{ background: #f8f9fa; }}
.comment-item {{
padding: 10px;
background: #f8f9fa;
margin: 5px 0;
border-radius: 5px;
border-left: 3px solid #007bff;
}}
.comment-header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}}
.actions {{
margin-top: 10px;
display: flex;
gap: 5px;
flex-wrap: wrap;
}}
.btn {{
padding: 5px 10px;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
transition: all 0.3s;
}}
.btn-delete {{ background: #dc3545; color: white; }}
.btn-delete:hover {{ background: #c82333; }}
.btn-edit {{ background: #ffc107; color: black; }}
.btn-edit:hover {{ background: #e0a800; }}
.btn-toggle {{ background: #6c757d; color: white; }}
.btn-toggle:hover {{ background: #5a6268; }}
.btn-refresh {{
background: #28a745;
color: white;
padding: 10px 20px;
font-size: 14px;
}}
.btn-refresh:hover {{ background: #218838; }}
.search-box {{
margin-bottom: 20px;
display: flex;
gap: 10px;
}}
input[type="text"] {{
flex: 1;
padding: 10px;
border: 2px solid #ddd;
border-radius: 5px;
font-size: 14px;
}}
.status-badge {{
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
margin-left: 10px;
}}
.visible {{ background: #d4edda; color: #155724; }}
.hidden {{ background: #f8d7da; color: #721c24; }}
.empty-state {{
text-align: center;
padding: 40px;
color: #666;
}}
.controls {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 10px;
}}
.action-success {{
background: #d4edda;
color: #155724;
padding: 10px;
border-radius: 5px;
margin-bottom: 10px;
display: none;
}}
</style>
<script>
function showSuccess(message) {{
var div = document.getElementById('actionSuccess');
div.textContent = message;
div.style.display = 'block';
setTimeout(() => div.style.display = 'none', 3000);
}}
function searchComments() {{
var input = document.getElementById('searchInput').value.toLowerCase();
var articles = document.querySelectorAll('.article-item');
articles.forEach(function(article) {{
var articleText = article.textContent.toLowerCase();
if (articleText.includes(input)) {{
article.style.display = 'block';
}} else {{
article.style.display = 'none';
}}
}});
}}
function toggleComment(articleId, commentId) {{
if (confirm(' 确定要切换评论的显示状态吗?')) {{
var formData = new FormData();
formData.append('token', '{token}');
formData.append('article_id', articleId);
formData.append('comment_id', commentId);
fetch('/comment/admin/toggle', {{
method: 'POST',
body: formData
}}).then(function(response) {{
if (response.ok) {{
showSuccess(' 评论状态已更新 ');
setTimeout(() => location.reload(), 1000);
}} else {{
alert(' 操作失败 ');
}}
}}).catch(function(error) {{
alert(' 网络错误: ' + error);
}});
}}
}}
function deleteComment(articleId, commentId) {{
if (confirm(' 确定要删除这条评论吗?此操作不可撤销!')) {{
var formData = new FormData();
formData.append('token', '{token}');
formData.append('article_id', articleId);
formData.append('comment_id', commentId);
fetch('/comment/admin/delete', {{
method: 'POST',
body: formData
}}).then(function(response) {{
if (response.ok) {{
showSuccess(' 评论已删除 ');
setTimeout(() => location.reload(), 1000);
}} else {{
alert(' 删除失败 ');
}}
}}).catch(function(error) {{
alert(' 网络错误: ' + error);
}});
}}
}}
function reloadPanel() {{
location.reload();
}}
</script>
</head>
<body>
<div class="header">
<div class="container">
<h1>📋 评论管理面板 </h1>
<p> 管理所有文章的评论内容 </p>
</div>
</div>
<div class="container">
<div id="actionSuccess" class="action-success"></div>
<div class="controls">
<div class="search-box">
<input type="text" id="searchInput" placeholder=" 搜索文章或评论内容..." onkeyup="searchComments()">
<button class="btn btn-refresh" onclick="reloadPanel()">🔄 刷新 </button>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<h3> 文章总数 </h3>
<div class="stat-number">{len(comments)}</div>
<p> 有评论的文章数量 </p>
</div>
<div class="stat-card">
<h3> 评论总数 </h3>
<div class="stat-number">{total_comments}</div>
<p> 所有评论的数量 </p>
</div>
<div class="stat-card">
<h3> 可见评论 </h3>
<div class="stat-number">{visible_comments}</div>
<p> 当前显示的评论 </p>
</div>
<div class="stat-card">
<h3> 隐藏评论 </h3>
<div class="stat-number">{total_comments - visible_comments}</div>
<p> 已隐藏的评论 </p>
</div>
</div>
<div class="article-list">'''
if not comments:
html += '''
<div class="empty-state">
<h3> 暂无评论数据 </h3>
<p> 还没有任何文章的评论 </p>
</div>'''
else:
for article_id, comment_list in sorted(comments.items()):
visible_comments = [c for c in comment_list if c.get("visible", True)]
hidden_comments = [c for c in comment_list if not c.get("visible", True)]
html += f'''
<div class="article-item">
<h3>
✎ {article_id}
<span style="font-size: 0.9em; color: #666;">
(可见: {len(visible_comments)}条, 隐藏: {len(hidden_comments)}条)
</span>
</h3>
<div>
<a href="/comment/{article_id}/list" target="_blank">📝 查看评论页 </a> |
<a href="/comment/{article_id}" target="_blank">➕ 发表评论 </a>
</div>
<div class="comments">'''
for comment in comment_list:
status = "✅ 可见" if comment.get("visible", True) else "🚫 隐藏"
status_class = "visible" if comment.get("visible", True) else "hidden"
html += f'''
<div class="comment-item">
<div class="comment-header">
<div>
<strong>{comment['username']}</strong>
<span class="status-badge {status_class}">{status}</span>
</div>
<div style="color: #666; font-size: 12px;">
{comment['timestamp']}
</div>
</div>
<div>{comment['content']}</div>
<div class="actions">
<button class="btn btn-toggle" onclick="toggleComment('{article_id}', '{comment['id']}')">
{ "隐藏 " if comment.get("visible", True) else " 显示" }
</button>
<button class="btn btn-delete" onclick="deleteComment('{article_id}', '{comment['id']}')">
删除
</button>
<span style="color: #888; font-size: 11px;">ID: {comment['id']}</span>
</div>
</div>'''
html += '''
</div>
</div>'''
html += '''
</div>
<div style="text-align: center; margin-top: 30px; color: #666; font-size: 14px;">
<p> 管理令牌: <code>{}</code></p>
<p> 最后更新: {}</p>
</div>
</div>
</body>
</html>'''.format(token, datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
return html
@app.route('/comment/admin/toggle', methods=['POST'])
def admin_toggle_comment():
"""切换评论显示状态"""
token = request.form.get('token', '')
if not check_admin_session():
return "未授权", 401
article_id = request.form.get('article_id', '')
comment_id = request.form.get('comment_id', '')
comments = load_comments()
if article_id in comments:
for comment in comments[article_id]:
if comment['id'] == comment_id:
comment['visible'] = not comment.get('visible', True)
success = save_comments(comments)
if success:
return "OK"
else:
return "保存失败", 500
return "评论不存在", 404
@app.route('/comment/admin/delete', methods=['POST'])
def admin_delete_comment():
"""删除评论"""
token = request.form.get('token', '')
if not check_admin_session():
return "未授权", 401
article_id = request.form.get('article_id', '')
comment_id = request.form.get('comment_id', '')
comments = load_comments()
if article_id in comments:
original_count = len(comments[article_id])
comments[article_id] = [c for c in comments[article_id] if c['id'] != comment_id]
if len(comments[article_id]) < original_count:
success = save_comments(comments)
if success:
return "OK"
else:
return "保存失败", 500
return "评论不存在", 404
@app.route('/comment/admin/logout')
def admin_logout():
"""管理员登出"""
token = request.args.get('token', '')
if token:
session_file = Path(f"admin_session_{token}.txt")
if session_file.exists():
session_file.unlink()
return redirect('/comment/admin/login')
# ==================== 初始化函数 ====================
def init_system():
"""初始化系统"""
# 初始化评论数据文件
if not COMMENTS_FILE.exists():
save_comments({
"demo/article/123": [
{
"id": "c20231201080000_abc123",
"username": "热心读者",
"content": "这篇文章非常有帮助,谢谢作者!",
"timestamp": "2023-12-01 08:00:00",
"visible": True
},
{
"id": "c20231201090000_def456",
"username": "技术小白",
"content": "讲解得很清楚,终于搞懂了这个概念。",
"timestamp": "2023-12-01 09:00:00",
"visible": True
}
]
})
print("✓ 创建了示例评论数据")
# 初始化管理员密码
init_admin_password()
print("=" * 60)
print("评论系统初始化完成!")
print("=" * 60)
print("\n📋 可用功能:")
print("-" * 40)
print("1. 评论表单: /comment/< 文章 ID>?redirect=< 返回 URL>")
print("2. 评论预览 SVG: /comment/< 文章 ID>/preview.svg")
print("3. 完整评论 SVG: /comment/< 文章 ID>/list.svg")
print("4. 评论列表 HTML: /comment/< 文章 ID>/list")
print("5. 管理面板: /comment/admin/login")
print("\n📝 示例:")
print("-" * 40)
print("文章评论页面: http://localhost:5000/comment/demo/article/123?redirect=/")
print("评论预览 SVG: http://localhost:5000/comment/demo/article/123/preview.svg")
print("管理面板: http://localhost:5000/comment/admin/login")
print("=" * 60)
Python
正文完

