diff --git a/app.py b/app.py
index 2390006..88fbbc1 100644
--- a/app.py
+++ b/app.py
@@ -1,11 +1,22 @@
# -*- coding: utf-8 -*-
"""云服务器价格对比 - Flask 应用"""
import io
+from time import monotonic
from datetime import datetime, timezone
from urllib.parse import urlencode
from flask import Flask, render_template, jsonify, request, redirect, url_for, session, send_file
from werkzeug.middleware.proxy_fix import ProxyFix
from sqlalchemy import text, func, or_
+from sqlalchemy.orm import joinedload
+from markupsafe import Markup, escape
+try:
+ import markdown as py_markdown
+except Exception:
+ py_markdown = None
+try:
+ import bleach
+except Exception:
+ bleach = None
from config import Config
from extensions import db
from openpyxl import Workbook
@@ -222,6 +233,122 @@ FORUM_NOTIFICATION_TYPE_LABELS = {
}
+# 论坛高频数据短时缓存(进程内)
+_FORUM_CACHE_TTL_CATEGORIES = 20.0
+_FORUM_CACHE_TTL_SIDEBAR = 15.0
+_FORUM_CATEGORY_CACHE = {}
+_FORUM_SIDEBAR_CACHE = {"expires_at": 0.0, "data": None}
+
+_MARKDOWN_ALLOWED_TAGS = [
+ "p", "br", "hr",
+ "h1", "h2", "h3", "h4",
+ "strong", "em", "del",
+ "ul", "ol", "li",
+ "blockquote",
+ "pre", "code",
+ "a",
+ "table", "thead", "tbody", "tr", "th", "td",
+]
+_MARKDOWN_ALLOWED_ATTRS = {
+ "a": ["href", "title", "target", "rel"],
+ "code": ["class"],
+ "pre": ["class"],
+}
+_MARKDOWN_EXTENSIONS = [
+ "fenced_code",
+ "tables",
+ "sane_lists",
+ "nl2br",
+]
+
+
+FORUM_NOTIFICATION_TYPE_LABELS_EN = {
+ "post_commented": "New comment",
+ "thread_replied": "New reply",
+ "report_processed": "Report update",
+ "content_removed": "Content moderation",
+}
+
+
+def _get_lang():
+ lang = (
+ request.args.get("lang")
+ or request.form.get("lang")
+ or session.get("lang")
+ or "zh"
+ )
+ lang = (lang or "zh").strip().lower()
+ if lang not in ("zh", "en"):
+ lang = "zh"
+ session["lang"] = lang
+ return lang
+
+
+def _pick_lang(zh_text, en_text, lang=None):
+ active_lang = lang or _get_lang()
+ return en_text if active_lang == "en" else zh_text
+
+
+def _lang_url(lang_code):
+ target_lang = (lang_code or "").strip().lower()
+ if target_lang not in ("zh", "en"):
+ target_lang = "zh"
+ params = {}
+ if request.view_args:
+ params.update(request.view_args)
+ params.update(request.args.to_dict(flat=True))
+ params["lang"] = target_lang
+ try:
+ if request.endpoint:
+ return url_for(request.endpoint, **params)
+ except Exception:
+ pass
+ return "{}?{}".format(request.path, urlencode(params))
+
+
+def _notification_type_label(notif_type, lang=None):
+ active_lang = lang or _get_lang()
+ if active_lang == "en":
+ return FORUM_NOTIFICATION_TYPE_LABELS_EN.get(notif_type, notif_type or "Notification")
+ return FORUM_NOTIFICATION_TYPE_LABELS.get(notif_type, notif_type or "通知")
+
+
+@app.template_global("l")
+def _template_pick_lang(zh_text, en_text):
+ active_lang = session.get("lang", "zh")
+ if active_lang not in ("zh", "en"):
+ active_lang = "zh"
+ return en_text if active_lang == "en" else zh_text
+
+
+@app.template_global("lang_url")
+def _template_lang_url(lang_code):
+ return _lang_url(lang_code)
+
+
+def _render_markdown_html(text):
+ raw = (text or "").strip()
+ if not raw:
+ return Markup("")
+ if py_markdown is None or bleach is None:
+ # 依赖缺失时回退为安全纯文本显示,避免服务启动失败。
+ return Markup("
{}
".format(str(escape(raw)).replace("\n", " ")))
+ html = py_markdown.markdown(raw, extensions=_MARKDOWN_EXTENSIONS)
+ clean_html = bleach.clean(
+ html,
+ tags=_MARKDOWN_ALLOWED_TAGS,
+ attributes=_MARKDOWN_ALLOWED_ATTRS,
+ protocols=["http", "https", "mailto"],
+ strip=True,
+ )
+ return Markup(clean_html)
+
+
+@app.template_filter("markdown_html")
+def markdown_html_filter(text):
+ return _render_markdown_html(text)
+
+
def _get_current_user():
user_id = session.get("user_id")
if not user_id:
@@ -292,10 +419,9 @@ def _create_notification(
def _notification_target_url(notification):
+ # 避免通知列表页按条检查帖子存在性导致 N+1 查询。
if notification.post_id:
- exists = db.session.get(ForumPost, notification.post_id)
- if exists:
- return url_for("forum_post_detail", post_id=notification.post_id)
+ return url_for("forum_post_detail", post_id=notification.post_id)
return url_for("user_notifications")
@@ -311,21 +437,32 @@ def _load_forum_categories(active_only=True):
def _get_forum_category_names(active_only=True):
+ cache_key = "active" if active_only else "all"
+ now_ts = monotonic()
+ cached = _FORUM_CATEGORY_CACHE.get(cache_key)
+ if cached and cached[0] > now_ts:
+ return list(cached[1])
+
rows = _load_forum_categories(active_only=active_only)
names = [x.name for x in rows if x.name]
if names:
+ _FORUM_CATEGORY_CACHE[cache_key] = (now_ts + _FORUM_CACHE_TTL_CATEGORIES, tuple(names))
return names
# 若全部被停用,前台仍回退到已存在分类,避免下拉为空。
if active_only:
rows = _load_forum_categories(active_only=False)
names = [x.name for x in rows if x.name]
if names:
+ _FORUM_CATEGORY_CACHE[cache_key] = (now_ts + _FORUM_CACHE_TTL_CATEGORIES, tuple(names))
return names
- return list(DEFAULT_FORUM_CATEGORIES)
+ fallback = list(DEFAULT_FORUM_CATEGORIES)
+ _FORUM_CATEGORY_CACHE[cache_key] = (now_ts + _FORUM_CACHE_TTL_CATEGORIES, tuple(fallback))
+ return fallback
@app.context_processor
def inject_global_user():
+ lang = _get_lang()
current_user = _get_current_user()
notifications_unread_count = 0
if current_user:
@@ -339,12 +476,14 @@ def inject_global_user():
"forum_categories": _get_forum_category_names(active_only=True),
"forum_report_reasons": FORUM_REPORT_REASONS,
"notifications_unread_count": notifications_unread_count,
+ "lang": lang,
}
-def _humanize_time(dt):
+def _humanize_time(dt, lang=None):
if not dt:
return ""
+ active_lang = lang or session.get("lang", "zh")
if dt.tzinfo is None:
now = datetime.utcnow()
else:
@@ -354,22 +493,26 @@ def _humanize_time(dt):
if seconds < 0:
return dt.strftime("%Y-%m-%d")
if seconds < 60:
- return "刚刚"
+ return "just now" if active_lang == "en" else "刚刚"
if seconds < 3600:
- return "{} 分钟前".format(seconds // 60)
+ mins = seconds // 60
+ return "{}m ago".format(mins) if active_lang == "en" else "{} 分钟前".format(mins)
if seconds < 86400:
- return "{} 小时前".format(seconds // 3600)
+ hours = seconds // 3600
+ return "{}h ago".format(hours) if active_lang == "en" else "{} 小时前".format(hours)
if seconds < 86400 * 14:
- return "{} 天前".format(seconds // 86400)
+ days = seconds // 86400
+ return "{}d ago".format(days) if active_lang == "en" else "{} 天前".format(days)
return dt.strftime("%Y-%m-%d")
-def _build_forum_post_cards(rows):
+def _build_forum_post_cards(rows, lang=None):
"""将论坛查询结果行转换为列表卡片数据。"""
+ active_lang = lang or _get_lang()
cards = []
for post, reply_count, latest_activity, author_name, like_count, bookmark_count in rows:
latest_activity = latest_activity or post.created_at
- username = author_name or "用户"
+ username = author_name or _pick_lang("用户", "User", active_lang)
cards.append({
"post": post,
"reply_count": int(reply_count or 0),
@@ -377,14 +520,14 @@ def _build_forum_post_cards(rows):
"like_count": int(like_count or 0),
"bookmark_count": int(bookmark_count or 0),
"latest_activity": latest_activity,
- "latest_activity_text": _humanize_time(latest_activity),
+ "latest_activity_text": _humanize_time(latest_activity, lang=active_lang),
"author_name": username,
"author_initial": (username[0] if username else "?").upper(),
})
return cards
-def _build_forum_url(tab="latest", category=None, q=None, page=1):
+def _build_forum_url(tab="latest", category=None, q=None, page=1, per_page=20):
"""构建论坛列表页链接,并尽量保持 URL 简洁。"""
params = {}
if (tab or "latest") != "latest":
@@ -395,6 +538,10 @@ def _build_forum_url(tab="latest", category=None, q=None, page=1):
params["q"] = q
if page and int(page) > 1:
params["page"] = int(page)
+ if per_page:
+ size = int(per_page)
+ if size != 20:
+ params["per_page"] = size
return url_for("forum_index", **params)
@@ -473,6 +620,11 @@ def _query_forum_post_rows(active_tab="latest", selected_category=None, search_q
def _forum_sidebar_data():
+ now_ts = monotonic()
+ cached = _FORUM_SIDEBAR_CACHE.get("data")
+ if cached is not None and _FORUM_SIDEBAR_CACHE.get("expires_at", 0.0) > now_ts:
+ return dict(cached)
+
category_counts = (
db.session.query(ForumPost.category, func.count(ForumPost.id))
.group_by(ForumPost.category)
@@ -487,13 +639,39 @@ def _forum_sidebar_data():
.limit(6)
.all()
)
- return {
+ data = {
"total_users": User.query.count(),
"total_posts": ForumPost.query.count(),
"total_comments": ForumComment.query.count(),
"category_counts": list(category_counts),
"active_users": list(active_users),
}
+ _FORUM_SIDEBAR_CACHE["data"] = data
+ _FORUM_SIDEBAR_CACHE["expires_at"] = now_ts + _FORUM_CACHE_TTL_SIDEBAR
+ return dict(data)
+
+
+def _count_forum_posts(selected_category=None, search_query=None, author_id=None):
+ """论坛列表总数查询:避免对重查询语句直接 count 导致慢查询。"""
+ q = (
+ db.session.query(func.count(ForumPost.id))
+ .select_from(ForumPost)
+ .outerjoin(User, User.id == ForumPost.user_id)
+ )
+ if selected_category:
+ q = q.filter(ForumPost.category == selected_category)
+ if author_id is not None:
+ q = q.filter(ForumPost.user_id == author_id)
+ if search_query:
+ pattern = "%{}%".format(search_query)
+ q = q.filter(
+ or_(
+ ForumPost.title.ilike(pattern),
+ ForumPost.content.ilike(pattern),
+ User.username.ilike(pattern),
+ )
+ )
+ return int(q.scalar() or 0)
def _currency_symbol(currency):
@@ -715,10 +893,7 @@ I18N = {
@app.route("/")
def index():
- lang = request.args.get("lang") or session.get("lang", "zh")
- if lang not in ("zh", "en"):
- lang = "zh"
- session["lang"] = lang
+ lang = _get_lang()
t = I18N[lang]
plans = VPSPlan.query.order_by(VPSPlan.provider, VPSPlan.price_cny).all()
return render_template(
@@ -740,6 +915,7 @@ def api_plans():
# ---------- 前台用户与论坛 ----------
@app.route("/register", methods=["GET", "POST"])
def user_register():
+ lang = _get_lang()
current = _get_current_user()
if current:
if _is_banned_user(current):
@@ -753,13 +929,17 @@ def user_register():
confirm_password = request.form.get("confirm_password") or ""
if not _is_valid_username(username):
- error = "用户名需为 3-20 位,仅支持字母、数字、下划线"
+ error = _pick_lang(
+ "用户名需为 3-20 位,仅支持字母、数字、下划线",
+ "Username must be 3-20 chars (letters, numbers, underscore).",
+ lang,
+ )
elif len(password) < 6:
- error = "密码至少 6 位"
+ error = _pick_lang("密码至少 6 位", "Password must be at least 6 characters.", lang)
elif password != confirm_password:
- error = "两次输入的密码不一致"
+ error = _pick_lang("两次输入的密码不一致", "Passwords do not match.", lang)
elif User.query.filter(func.lower(User.username) == username.lower()).first():
- error = "用户名已存在"
+ error = _pick_lang("用户名已存在", "Username already exists.", lang)
else:
user = User(username=username)
user.set_password(password)
@@ -774,6 +954,7 @@ def user_register():
@app.route("/login", methods=["GET", "POST"])
def user_login():
+ lang = _get_lang()
current = _get_current_user()
if current:
if _is_banned_user(current):
@@ -786,7 +967,7 @@ def user_login():
password = request.form.get("password") or ""
user = User.query.filter(func.lower(User.username) == username.lower()).first()
if not user or not user.check_password(password):
- error = "用户名或密码错误"
+ error = _pick_lang("用户名或密码错误", "Invalid username or password.", lang)
elif _is_banned_user(user):
error = _user_ban_message(user)
else:
@@ -811,6 +992,7 @@ def user_profile_redirect():
@app.route("/me", methods=["GET", "POST"])
@user_login_required
def user_profile():
+ lang = _get_lang()
user = _get_current_user()
tab = (request.args.get("tab") or "posts").strip().lower()
if tab not in {"posts", "comments", "likes", "bookmarks", "settings"}:
@@ -821,42 +1003,50 @@ def user_profile():
if action == "profile":
username = (request.form.get("username") or "").strip()
if username == user.username:
- return redirect(url_for("user_profile", tab="settings", msg="资料未变更"))
+ return redirect(url_for("user_profile", tab="settings", msg=_pick_lang("资料未变更", "No changes detected.", lang)))
if not _is_valid_username(username):
- return redirect(url_for("user_profile", tab="settings", error="用户名需为 3-20 位,仅支持字母、数字、下划线"))
+ return redirect(url_for(
+ "user_profile",
+ tab="settings",
+ error=_pick_lang(
+ "用户名需为 3-20 位,仅支持字母、数字、下划线",
+ "Username must be 3-20 chars (letters, numbers, underscore).",
+ lang,
+ ),
+ ))
exists = (
User.query
.filter(func.lower(User.username) == username.lower(), User.id != user.id)
.first()
)
if exists:
- return redirect(url_for("user_profile", tab="settings", error="用户名已存在"))
+ return redirect(url_for("user_profile", tab="settings", error=_pick_lang("用户名已存在", "Username already exists.", lang)))
user.username = username
db.session.commit()
- return redirect(url_for("user_profile", tab="settings", msg="用户名已更新"))
+ return redirect(url_for("user_profile", tab="settings", msg=_pick_lang("用户名已更新", "Username updated.", lang)))
if action == "password":
current_password = request.form.get("current_password") or ""
new_password = request.form.get("new_password") or ""
confirm_password = request.form.get("confirm_password") or ""
if not user.check_password(current_password):
- return redirect(url_for("user_profile", tab="settings", error="当前密码错误"))
+ return redirect(url_for("user_profile", tab="settings", error=_pick_lang("当前密码错误", "Current password is incorrect.", lang)))
if len(new_password) < 6:
- return redirect(url_for("user_profile", tab="settings", error="新密码至少 6 位"))
+ return redirect(url_for("user_profile", tab="settings", error=_pick_lang("新密码至少 6 位", "New password must be at least 6 characters.", lang)))
if new_password != confirm_password:
- return redirect(url_for("user_profile", tab="settings", error="两次新密码输入不一致"))
+ return redirect(url_for("user_profile", tab="settings", error=_pick_lang("两次新密码输入不一致", "New passwords do not match.", lang)))
user.set_password(new_password)
db.session.commit()
- return redirect(url_for("user_profile", tab="settings", msg="密码已更新"))
+ return redirect(url_for("user_profile", tab="settings", msg=_pick_lang("密码已更新", "Password updated.", lang)))
- return redirect(url_for("user_profile", tab="settings", error="未知操作"))
+ return redirect(url_for("user_profile", tab="settings", error=_pick_lang("未知操作", "Unknown action.", lang)))
my_post_rows = (
_query_forum_post_rows(active_tab="latest", author_id=user.id)
.limit(60)
.all()
)
- my_post_cards = _build_forum_post_cards(my_post_rows)
+ my_post_cards = _build_forum_post_cards(my_post_rows, lang=lang)
my_comment_rows = (
db.session.query(
ForumComment,
@@ -952,11 +1142,16 @@ def user_profile():
@app.route("/notifications")
@user_login_required
def user_notifications():
+ lang = _get_lang()
user = _get_current_user()
status = (request.args.get("status") or "all").strip().lower()
if status not in {"all", "unread", "read"}:
status = "all"
- q = ForumNotification.query.filter_by(user_id=user.id)
+ q = (
+ ForumNotification.query
+ .filter_by(user_id=user.id)
+ .options(joinedload(ForumNotification.actor_rel))
+ )
if status == "unread":
q = q.filter_by(is_read=False)
elif status == "read":
@@ -966,13 +1161,24 @@ def user_notifications():
for n in rows:
items.append({
"notification": n,
- "type_label": FORUM_NOTIFICATION_TYPE_LABELS.get(n.notif_type, n.notif_type or "通知"),
+ "type_label": _notification_type_label(n.notif_type, lang=lang),
"actor_name": n.actor_rel.username if n.actor_rel else "",
"target_url": _notification_target_url(n),
- "time_text": _humanize_time(n.created_at),
+ "time_text": _humanize_time(n.created_at, lang=lang),
})
- unread_count = ForumNotification.query.filter_by(user_id=user.id, is_read=False).count()
- read_count = ForumNotification.query.filter_by(user_id=user.id, is_read=True).count()
+ status_rows = (
+ db.session.query(ForumNotification.is_read, func.count(ForumNotification.id))
+ .filter_by(user_id=user.id)
+ .group_by(ForumNotification.is_read)
+ .all()
+ )
+ read_count = 0
+ unread_count = 0
+ for is_read, count_val in status_rows:
+ if bool(is_read):
+ read_count = int(count_val or 0)
+ else:
+ unread_count = int(count_val or 0)
return render_template(
"forum/notifications.html",
active_status=status,
@@ -988,10 +1194,11 @@ def user_notifications():
@app.route("/notification//go")
@user_login_required
def user_notification_go(notification_id):
+ lang = _get_lang()
user = _get_current_user()
n = ForumNotification.query.get_or_404(notification_id)
if n.user_id != user.id:
- return redirect(url_for("user_notifications", error="无权访问该通知"))
+ return redirect(url_for("user_notifications", error=_pick_lang("无权访问该通知", "Permission denied for this notification.", lang)))
if not n.is_read:
n.is_read = True
db.session.commit()
@@ -1001,32 +1208,36 @@ def user_notification_go(notification_id):
@app.route("/notification//read", methods=["POST"])
@user_login_required
def user_notification_read(notification_id):
+ lang = _get_lang()
user = _get_current_user()
n = ForumNotification.query.get_or_404(notification_id)
if n.user_id != user.id:
- return redirect(url_for("user_notifications", error="无权操作该通知"))
+ return redirect(url_for("user_notifications", error=_pick_lang("无权操作该通知", "Permission denied for this notification.", lang)))
if not n.is_read:
n.is_read = True
db.session.commit()
next_url = (request.form.get("next") or "").strip()
if next_url.startswith("/") and not next_url.startswith("//"):
return redirect(next_url)
- return redirect(url_for("user_notifications", msg="已标记为已读"))
+ return redirect(url_for("user_notifications", msg=_pick_lang("已标记为已读", "Marked as read.", lang)))
@app.route("/notifications/read-all", methods=["POST"])
@user_login_required
def user_notifications_read_all():
+ lang = _get_lang()
user = _get_current_user()
unread = ForumNotification.query.filter_by(user_id=user.id, is_read=False)
updated = unread.update({"is_read": True}, synchronize_session=False)
db.session.commit()
- msg = "已全部标记为已读" if updated else "没有未读通知"
+ msg = _pick_lang("已全部标记为已读", "All notifications marked as read.", lang) if updated else _pick_lang("没有未读通知", "No unread notifications.", lang)
return redirect(url_for("user_notifications", msg=msg))
@app.route("/forum")
def forum_index():
+ lang = _get_lang()
+ per_page_options = [10, 20, 30, 50]
active_tab = (request.args.get("tab") or "latest").strip().lower()
if active_tab not in {"latest", "new", "hot"}:
active_tab = "latest"
@@ -1039,19 +1250,24 @@ def forum_index():
page = request.args.get("page", type=int) or 1
if page < 1:
page = 1
- per_page = 20
+ per_page = request.args.get("per_page", type=int) or 20
+ if per_page not in per_page_options:
+ per_page = 20
rows_query = _query_forum_post_rows(
active_tab=active_tab,
selected_category=selected_category,
search_query=search_query or None,
)
- total_posts = rows_query.order_by(None).count()
+ total_posts = _count_forum_posts(
+ selected_category=selected_category,
+ search_query=search_query or None,
+ )
total_pages = max((total_posts + per_page - 1) // per_page, 1)
if page > total_pages:
page = total_pages
rows = rows_query.offset((page - 1) * per_page).limit(per_page).all()
- post_cards = _build_forum_post_cards(rows)
+ post_cards = _build_forum_post_cards(rows, lang=lang)
sidebar = _forum_sidebar_data()
category_count_map = {name: int(count or 0) for name, count in (sidebar.get("category_counts") or [])}
@@ -1063,9 +1279,9 @@ def forum_index():
category_names.insert(0, selected_category)
tab_defs = [
- ("latest", "最新"),
- ("new", "新帖"),
- ("hot", "热门"),
+ ("latest", _pick_lang("最新", "Latest", lang)),
+ ("new", _pick_lang("新帖", "New", lang)),
+ ("hot", _pick_lang("热门", "Top", lang)),
]
tab_links = [
{
@@ -1076,6 +1292,7 @@ def forum_index():
category=selected_category,
q=search_query or None,
page=1,
+ per_page=per_page,
),
"active": active_tab == key,
}
@@ -1083,9 +1300,15 @@ def forum_index():
]
category_links = [
{
- "name": "全部",
+ "name": _pick_lang("全部", "All", lang),
"count": None,
- "url": _build_forum_url(tab=active_tab, category=None, q=search_query or None, page=1),
+ "url": _build_forum_url(
+ tab=active_tab,
+ category=None,
+ q=search_query or None,
+ page=1,
+ per_page=per_page,
+ ),
"active": selected_category is None,
}
]
@@ -1093,7 +1316,13 @@ def forum_index():
category_links.append({
"name": name,
"count": category_count_map.get(name, 0),
- "url": _build_forum_url(tab=active_tab, category=name, q=search_query or None, page=1),
+ "url": _build_forum_url(
+ tab=active_tab,
+ category=name,
+ q=search_query or None,
+ page=1,
+ per_page=per_page,
+ ),
"active": selected_category == name,
})
@@ -1102,6 +1331,7 @@ def forum_index():
category=selected_category or (category_names[0] if category_names else None),
q=search_query or None,
page=1,
+ per_page=per_page,
)
window_start = max(1, page - 2)
@@ -1114,6 +1344,7 @@ def forum_index():
category=selected_category,
q=search_query or None,
page=num,
+ per_page=per_page,
),
"active": num == page,
}
@@ -1122,13 +1353,13 @@ def forum_index():
has_filters = bool(selected_category or search_query or active_tab != "latest")
if search_query and selected_category:
- empty_hint = "当前分类下没有匹配关键词的帖子。"
+ empty_hint = _pick_lang("当前分类下没有匹配关键词的帖子。", "No posts match your keywords in this category.", lang)
elif search_query:
- empty_hint = "没有匹配关键词的帖子。"
+ empty_hint = _pick_lang("没有匹配关键词的帖子。", "No posts match your keywords.", lang)
elif selected_category:
- empty_hint = "该分类暂时没有帖子。"
+ empty_hint = _pick_lang("该分类暂时没有帖子。", "No posts in this category yet.", lang)
else:
- empty_hint = "当前没有帖子,点击右上角按钮发布第一条内容。"
+ empty_hint = _pick_lang("当前没有帖子,点击右上角按钮发布第一条内容。", "No posts yet. Create the first topic from the top-right button.", lang)
result_start = ((page - 1) * per_page + 1) if total_posts else 0
result_end = min(page * per_page, total_posts) if total_posts else 0
@@ -1154,24 +1385,29 @@ def forum_index():
category=selected_category,
q=search_query or None,
page=page - 1,
+ per_page=per_page,
),
next_page_url=_build_forum_url(
tab=active_tab,
category=selected_category,
q=search_query or None,
page=page + 1,
+ per_page=per_page,
),
clear_search_url=_build_forum_url(
tab=active_tab,
category=selected_category,
q=None,
page=1,
+ per_page=per_page,
),
- clear_all_url=_build_forum_url(tab="latest", category=None, q=None, page=1),
+ clear_all_url=_build_forum_url(tab="latest", category=None, q=None, page=1, per_page=per_page),
has_filters=has_filters,
empty_hint=empty_hint,
result_start=result_start,
result_end=result_end,
+ per_page=per_page,
+ per_page_options=per_page_options,
message=request.args.get("msg") or "",
error=request.args.get("error") or "",
)
@@ -1180,6 +1416,7 @@ def forum_index():
@app.route("/forum/post/new", methods=["GET", "POST"])
@user_login_required
def forum_post_new():
+ lang = _get_lang()
user = _get_current_user()
blocked_resp = _ensure_forum_interaction_user(user)
if blocked_resp:
@@ -1196,11 +1433,11 @@ def forum_post_new():
if category not in available_categories:
category = available_categories[0] if available_categories else "综合讨论"
if len(title) < 5:
- error = "标题至少 5 个字符"
+ error = _pick_lang("标题至少 5 个字符", "Title must be at least 5 characters.", lang)
elif len(title) > 160:
- error = "标题不能超过 160 个字符"
+ error = _pick_lang("标题不能超过 160 个字符", "Title must be 160 characters or fewer.", lang)
elif len(content) < 10:
- error = "内容至少 10 个字符"
+ error = _pick_lang("内容至少 10 个字符", "Content must be at least 10 characters.", lang)
else:
post = ForumPost(
user_id=user.id,
@@ -1218,8 +1455,8 @@ def forum_post_new():
content_val=content,
category_val=category,
categories=available_categories,
- page_title="创建新主题",
- submit_text="发布主题",
+ page_title=_pick_lang("创建新主题", "Create Topic", lang),
+ submit_text=_pick_lang("发布主题", "Publish", lang),
action_url=url_for("forum_post_new"),
cancel_url=url_for("forum_index"),
form_mode="create",
@@ -1229,6 +1466,7 @@ def forum_post_new():
@app.route("/forum/post//edit", methods=["GET", "POST"])
@user_login_required
def forum_post_edit(post_id):
+ lang = _get_lang()
post = ForumPost.query.get_or_404(post_id)
user = _get_current_user()
blocked_resp = _ensure_forum_interaction_user(user, post_id=post.id)
@@ -1251,11 +1489,11 @@ def forum_post_edit(post_id):
if category not in available_categories:
category = available_categories[0] if available_categories else "综合讨论"
if len(title) < 5:
- error = "标题至少 5 个字符"
+ error = _pick_lang("标题至少 5 个字符", "Title must be at least 5 characters.", lang)
elif len(title) > 160:
- error = "标题不能超过 160 个字符"
+ error = _pick_lang("标题不能超过 160 个字符", "Title must be 160 characters or fewer.", lang)
elif len(content) < 10:
- error = "内容至少 10 个字符"
+ error = _pick_lang("内容至少 10 个字符", "Content must be at least 10 characters.", lang)
else:
post.title = title
post.content = content
@@ -1270,8 +1508,8 @@ def forum_post_edit(post_id):
content_val=content,
category_val=category,
categories=available_categories,
- page_title="编辑主题",
- submit_text="保存修改",
+ page_title=_pick_lang("编辑主题", "Edit Topic", lang),
+ submit_text=_pick_lang("保存修改", "Save Changes", lang),
action_url=url_for("forum_post_edit", post_id=post.id),
cancel_url=url_for("forum_post_detail", post_id=post.id),
form_mode="edit",
@@ -1305,6 +1543,7 @@ def forum_post_detail(post_id):
db.session.commit()
comments = (
ForumComment.query
+ .options(joinedload(ForumComment.author_rel))
.filter_by(post_id=post.id)
.order_by(ForumComment.created_at.asc(), ForumComment.id.asc())
.all()
@@ -1591,6 +1830,11 @@ Sitemap: {url}/sitemap.xml
return resp
+@app.route("/favicon.ico")
+def favicon():
+ return redirect(url_for("static", filename="img/site-logo-mark.svg"))
+
+
# ---------- 后台 ----------
@app.route("/admin/login", methods=["GET", "POST"])
def admin_login():
diff --git a/requirements.txt b/requirements.txt
index 84be02d..28df2a8 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,3 +3,5 @@ flask-sqlalchemy>=3.1.0
PyMySQL>=1.1.0
cryptography>=41.0.0
openpyxl>=3.1.0
+markdown>=3.6
+bleach>=6.1.0
diff --git a/seed_forum_demo.py b/seed_forum_demo.py
new file mode 100644
index 0000000..0465222
--- /dev/null
+++ b/seed_forum_demo.py
@@ -0,0 +1,656 @@
+# -*- coding: utf-8 -*-
+"""批量生成论坛演示内容:用户、帖子、评论、点赞、收藏。"""
+from datetime import datetime, timedelta, timezone
+
+from app import app, db
+from models import User, ForumPost, ForumComment, ForumCategory, ForumPostLike, ForumPostBookmark
+
+
+DEFAULT_PASSWORD = "Forum123456"
+
+USERS = [
+ {"username": "ops_alan", "password": DEFAULT_PASSWORD},
+ {"username": "dev_yuki", "password": DEFAULT_PASSWORD},
+ {"username": "cloud_nana", "password": DEFAULT_PASSWORD},
+ {"username": "linux_mason", "password": DEFAULT_PASSWORD},
+ {"username": "sec_neo", "password": DEFAULT_PASSWORD},
+ {"username": "ai_rookie", "password": DEFAULT_PASSWORD},
+]
+
+
+POSTS = [
+ {
+ "title": "Ubuntu 22.04 安装宝塔面板后的 10 项安全加固(实战清单)",
+ "author": "ops_alan",
+ "category": "运维经验",
+ "days_ago": 7,
+ "is_featured": True,
+ "content": """背景:很多同学装完宝塔就直接上线,结果 2-3 天就被扫端口、爆破后台。
+
+我自己在生产环境里执行的加固顺序如下,基本可以在 30 分钟内做完:
+1. 安装后第一时间修改宝塔面板入口和端口,不使用默认路径。
+2. 宝塔后台开启登录限制(失败次数锁定 + 验证码 + 仅允许白名单 IP)。
+3. SSH 禁止 root 密码登录,只保留密钥登录;同时改 SSH 端口并记录在资产台账。
+4. 开启 UFW / firewalld,只放行必须端口(22/80/443),其余全部拒绝。
+5. 云厂商安全组与系统防火墙双层控制,避免“安全组放行过宽”。
+6. 删除不必要的默认站点,避免目录遍历和弱口令后台暴露。
+7. 数据库不要对公网开放 3306,应用与数据库尽量走内网。
+8. 配置定时备份(站点、数据库、配置文件),并异地存储。
+9. 安装 fail2ban,对 SSH 和 Web 登录做自动封禁。
+10. 上线后立即做一次端口扫描与弱口令自检,确认暴露面。
+
+建议:宝塔是效率工具,不是安全工具。上线前至少做一次“攻击面最小化”检查。""",
+ "comments": [
+ ("sec_neo", "这个清单很实用,尤其是 SSH 密钥登录 + fail2ban,很多人会忽略。"),
+ ("cloud_nana", "补充一点:最好把宝塔登录入口放到仅内网访问,再通过跳板机管理。"),
+ ],
+ "likes": ["dev_yuki", "cloud_nana", "linux_mason", "sec_neo"],
+ "bookmarks": ["dev_yuki", "ai_rookie", "linux_mason"],
+ },
+ {
+ "title": "服务器开放端口的正确姿势:安全组、防火墙、服务监听三层排查",
+ "author": "sec_neo",
+ "category": "运维经验",
+ "days_ago": 6,
+ "content": """很多“端口开了但访问不到”的问题,本质是三层中有一层没打通。
+
+排查顺序建议固定为:
+第一层:云安全组
+- 入站规则是否允许目标端口(如 80/443);
+- 来源 CIDR 是否过于严格(如只放了办公网);
+- 是否误把规则加在了错误的网卡或实例组。
+
+第二层:系统防火墙
+- `ufw status` / `firewall-cmd --list-all` 检查是否放行;
+- 是否有默认拒绝策略导致端口被拦;
+- 改完记得 reload。
+
+第三层:服务监听
+- `ss -lntp | grep 80` 检查进程是否监听;
+- 注意监听地址是 `0.0.0.0` 还是 `127.0.0.1`;
+- 应用容器端口映射是否正确(Docker 常见问题)。
+
+最后一步:
+- 本机 `curl 127.0.0.1:端口`;
+- 内网 `curl 内网IP:端口`;
+- 外网 `curl 公网IP:端口`。
+
+这样基本 10-15 分钟能定位到具体环节。""",
+ "comments": [
+ ("ops_alan", "三层模型非常清晰,新手排障直接照这个顺序来就行。"),
+ ("ai_rookie", "我之前就是服务只监听 127.0.0.1,难怪外网怎么都不通。"),
+ ],
+ "likes": ["ops_alan", "dev_yuki", "ai_rookie"],
+ "bookmarks": ["ops_alan", "cloud_nana", "ai_rookie"],
+ },
+ {
+ "title": "Nginx 绑定域名 + HTTPS 全流程(含 301、HSTS、自动续期)",
+ "author": "cloud_nana",
+ "category": "运维经验",
+ "days_ago": 5,
+ "is_featured": True,
+ "content": """这篇给一个可直接落地的域名绑定流程,适合新站上线:
+
+步骤 1:DNS 解析
+- A 记录指向服务器公网 IP;
+- 等待生效(通常 1-10 分钟,部分服务商更久)。
+
+步骤 2:Nginx server block
+- `server_name` 填主域名 + www;
+- 先用 HTTP 跑通站点,不要一开始就上证书。
+
+步骤 3:申请证书(Let's Encrypt)
+- 推荐 certbot;
+- 申请成功后检查证书路径和 Nginx 引用是否一致。
+
+步骤 4:强制 HTTPS
+- 80 端口统一做 301 跳转到 https;
+- 保留 ACME 验证路径例外(如果你用 webroot 方式)。
+
+步骤 5:安全头
+- 开启 HSTS(先短时间,再逐步拉长);
+- 可补充 `X-Content-Type-Options`、`X-Frame-Options`。
+
+步骤 6:自动续期
+- `certbot renew --dry-run` 先验证;
+- 用 systemd timer / crontab 定期续期并 reload Nginx。
+
+上线后建议在 SSL Labs 跑一遍,确保协议和套件配置达标。""",
+ "comments": [
+ ("linux_mason", "赞同先 HTTP 再 HTTPS,很多人一上来配证书容易定位困难。"),
+ ("sec_neo", "HSTS 建议分阶段,别一开始就 preload,回滚会很麻烦。"),
+ ],
+ "likes": ["ops_alan", "linux_mason", "sec_neo", "dev_yuki"],
+ "bookmarks": ["ops_alan", "linux_mason", "ai_rookie"],
+ },
+ {
+ "title": "OpenClaw 在 Ubuntu 服务器安装与 systemd 守护(可维护版)",
+ "author": "dev_yuki",
+ "category": "运维经验",
+ "days_ago": 4,
+ "content": """分享我在 Ubuntu 22.04 上部署 OpenClaw 的方式,重点是“稳定运行 + 易于维护”。
+
+推荐目录结构:
+- `/opt/openclaw`:程序目录
+- `/etc/openclaw/`:配置目录
+- `/var/log/openclaw/`:日志目录
+
+部署建议:
+1. 用独立系统用户运行(不要用 root)。
+2. 配置文件与程序文件分离,便于升级回滚。
+3. 写 systemd 服务:
+ - `Restart=always`
+ - `RestartSec=5`
+ - 限制权限(`NoNewPrivileges=true` 等)
+4. 日志统一走 journald 或文件滚动,避免磁盘被打满。
+5. 升级流程采用“解压新版本 -> 切换软链 -> 重启服务”。
+
+上线前 checklist:
+- `systemctl status openclaw`
+- `journalctl -u openclaw -n 200`
+- 健康检查接口可用
+- 端口仅对需要的来源开放
+
+这样做的好处是:故障定位快、升级可回滚、不会因为单次异常导致服务长期不可用。""",
+ "comments": [
+ ("ops_alan", "目录分层 + 软链切换这个方案很稳,适合长期维护。"),
+ ("cloud_nana", "建议再加个健康检查脚本,配合告警一起用效果更好。"),
+ ],
+ "likes": ["ops_alan", "cloud_nana", "linux_mason"],
+ "bookmarks": ["ops_alan", "sec_neo", "ai_rookie"],
+ },
+ {
+ "title": "宝塔部署 Flask:Gunicorn + Nginx + Supervisor 一次跑通",
+ "author": "linux_mason",
+ "category": "运维经验",
+ "days_ago": 3,
+ "content": """很多同学卡在“本地能跑,宝塔上线 502”。我这边给一个稳定组合:
+
+架构:
+Nginx (80/443) -> Gunicorn (127.0.0.1:8000) -> Flask App
+
+关键点:
+1. Flask 不直接对外暴露,Gunicorn 只监听本机回环地址。
+2. Nginx `proxy_pass` 指向 Gunicorn,注意 `proxy_set_header` 要完整。
+3. Gunicorn worker 数量按 CPU 计算,不要盲目拉满。
+4. 用 Supervisor/systemd 托管 Gunicorn,防止进程意外退出。
+5. 目录权限统一,避免静态文件 403。
+
+常见坑:
+- 虚拟环境没激活导致依赖缺失;
+- Nginx 与 Gunicorn socket/端口不一致;
+- 项目根目录配置错误导致模块导入失败。
+
+建议每次发布后执行:
+- `nginx -t`
+- 访问健康检查 URL
+- 查看 Nginx 和应用日志是否有 5xx。""",
+ "comments": [
+ ("dev_yuki", "Gunicorn 只监听 127.0.0.1 这点很重要,安全收益很高。"),
+ ("ai_rookie", "终于理解 502 的定位方式了,之前一直只看应用日志。"),
+ ],
+ "likes": ["ops_alan", "dev_yuki", "cloud_nana", "ai_rookie"],
+ "bookmarks": ["cloud_nana", "ai_rookie", "sec_neo"],
+ },
+ {
+ "title": "为什么放行了 80/443 还是打不开网站?15 分钟排障流程",
+ "author": "ai_rookie",
+ "category": "新手提问",
+ "days_ago": 2,
+ "is_pinned": True,
+ "content": """我把安全组 80/443 放开了,但浏览器还是超时。请大家帮忙看看排障顺序是否正确:
+
+我目前做了:
+1. 云安全组入站已放行 80/443(来源 0.0.0.0/0)。
+2. Ubuntu 上 `ufw allow 80,443/tcp`。
+3. Nginx 已启动,`systemctl status nginx` 显示 active。
+4. 域名 A 记录已指向服务器公网 IP。
+
+我准备进一步排查:
+- `ss -lntp | grep :80` 确认监听地址;
+- 本机 curl 127.0.0.1;
+- 外网 curl 公网 IP;
+- 检查 Nginx default server 是否误拦截。
+
+如果还有常见遗漏项,请大家补充一下,我整理后回帖反馈结果。""",
+ "comments": [
+ ("sec_neo", "再查一下运营商端口封禁和实例是否有公网带宽,这两个也常见。"),
+ ("cloud_nana", "域名解析生效可以用 dig/nslookup 验证,避免本地 DNS 缓存干扰。"),
+ ("ops_alan", "可以先不用域名,直接公网 IP 打通后再回到域名层。"),
+ ],
+ "likes": ["ops_alan", "cloud_nana", "dev_yuki", "linux_mason"],
+ "bookmarks": ["ops_alan", "dev_yuki"],
+ },
+ {
+ "title": "新服务器首日初始化 SOP:账号、时间、日志、监控一步到位",
+ "author": "ops_alan",
+ "category": "运维经验",
+ "days_ago": 1,
+ "content": """为了避免“上线后再补安全”这种被动局面,我把新机初始化收敛成一个 SOP:
+
+基础项:
+- 创建普通运维账号 + sudo;
+- 配置 SSH 密钥登录;
+- 关闭 root 密码登录;
+- 设置时区、NTP 同步。
+
+系统项:
+- 安装常用诊断工具(curl, wget, vim, htop, lsof, ss);
+- 配置日志轮转;
+- 开启防火墙并最小放行。
+
+可观测项:
+- 主机监控(CPU、内存、磁盘、负载);
+- 进程可用性检查;
+- 磁盘与证书过期告警。
+
+交付项:
+- 资产信息登记(IP、用途、负责人、到期时间);
+- 变更记录模板;
+- 回滚方案。
+
+这个流程固定下来后,服务器上线质量会稳定很多。""",
+ "comments": [
+ ("linux_mason", "强烈建议把资产台账自动化,不然机器一多很容易混乱。"),
+ ("sec_neo", "同意,首日就把监控和告警接好,能省很多夜间故障时间。"),
+ ],
+ "likes": ["dev_yuki", "cloud_nana", "linux_mason", "sec_neo", "ai_rookie"],
+ "bookmarks": ["dev_yuki", "cloud_nana", "ai_rookie"],
+ },
+ {
+ "title": "服务器端口规划建议:Web、数据库、SSH 与内网隔离实践",
+ "author": "sec_neo",
+ "category": "综合讨论",
+ "days_ago": 1,
+ "is_featured": True,
+ "content": """分享一个适合中小团队的端口规划思路,核心目标是“暴露最小化”:
+
+公网可见:
+- 80/443:Web 流量入口(建议统一走 Nginx/网关)
+- 22:仅白名单 IP,最好配合堡垒机
+
+仅内网可见:
+- 3306/5432:数据库
+- 6379:缓存
+- 9200:搜索服务(若有)
+
+管理面:
+- 面板、监控、日志平台尽量不直接暴露公网;
+- 必要时使用 VPN / 跳板机访问。
+
+执行原则:
+1. 先拒绝,后放行(default deny)。
+2. 安全组与主机防火墙同时配置。
+3. 定期做“端口盘点”和“僵尸规则清理”。
+
+端口规划不是一次性工作,建议每月复盘一次,防止规则持续膨胀。""",
+ "comments": [
+ ("ops_alan", "我们团队也是按这个思路做,尤其是数据库绝不出公网。"),
+ ("dev_yuki", "建议再加一条:高危端口变更必须走审批和审计。"),
+ ],
+ "likes": ["ops_alan", "cloud_nana", "dev_yuki", "linux_mason"],
+ "bookmarks": ["ops_alan", "linux_mason", "ai_rookie", "cloud_nana"],
+ },
+ {
+ "title": "宝塔里 Nginx 显示运行中,但网站一直 502,应该按什么顺序排查?",
+ "author": "ai_rookie",
+ "category": "新手提问",
+ "days_ago": 0,
+ "is_pinned": True,
+ "content": """我在宝塔上部署了 Flask,Nginx 状态是绿色“运行中”,但是访问域名一直 502。
+
+目前我确认过:
+1. 域名解析已经指向服务器 IP;
+2. 80/443 在安全组里放行;
+3. 应用进程偶尔会起来,但不稳定。
+
+请问排查顺序是不是这样更高效?
+- 看 Nginx 错误日志(确认是 upstream 超时还是连接拒绝);
+- 用 `ss -lntp` 看 Gunicorn 是否在监听;
+- 本机 curl `127.0.0.1:应用端口`;
+- 检查宝塔反向代理的目标端口是否写错;
+- 查看应用日志是否有导入错误或环境变量缺失。
+
+如果还有常见坑,请大家补充一下,我整理成 checklist。""",
+ "comments": [
+ ("linux_mason", "顺序正确。先从 Nginx error.log 判断是 connect refused 还是 timeout,再决定下一步。"),
+ ("dev_yuki", "补充:检查 Python 虚拟环境路径,宝塔里最容易因为路径错导致 Gunicorn 启动失败。"),
+ ("ops_alan", "再确认一下 systemd/supervisor 是否开启自动拉起,不然进程崩了就一直 502。"),
+ ],
+ "likes": ["ops_alan", "linux_mason", "dev_yuki", "cloud_nana"],
+ "bookmarks": ["ops_alan", "dev_yuki", "sec_neo"],
+ },
+ {
+ "title": "域名解析已经改了,为什么访问还是旧服务器?DNS 缓存怎么判断?",
+ "author": "ai_rookie",
+ "category": "新手提问",
+ "days_ago": 0,
+ "content": """我把 A 记录改到新 VPS 后,自己电脑访问还是旧站点,但手机 4G 打开有时是新站。
+
+我怀疑是 DNS 缓存问题,想确认排查步骤:
+1. `dig 域名 +short` 看当前解析结果;
+2. 对比不同 DNS(8.8.8.8 / 1.1.1.1 / 本地运营商);
+3. 清本地 DNS 缓存 + 浏览器缓存;
+4. 检查是否还有 AAAA 记录指向旧机器;
+5. 观察 TTL 到期时间。
+
+有没有更稳妥的切换方案,避免业务迁移时出现“部分用户命中旧站”?""",
+ "comments": [
+ ("cloud_nana", "迁移前建议先把 TTL 从 600 调到 60,等全网生效后再切 IP。"),
+ ("sec_neo", "你提到 AAAA 很关键,很多人只改 A 记录,IPv6 用户会继续走旧站。"),
+ ],
+ "likes": ["cloud_nana", "sec_neo", "ops_alan"],
+ "bookmarks": ["ops_alan", "linux_mason", "dev_yuki"],
+ },
+ {
+ "title": "Ubuntu 放行了 8080 还是连不上,安全组和防火墙到底谁优先?",
+ "author": "ai_rookie",
+ "category": "新手提问",
+ "days_ago": 0,
+ "content": """我在服务器里执行了 `ufw allow 8080/tcp`,应用也监听 `0.0.0.0:8080`,但外网还是连不上。
+
+我想搞清楚优先关系:
+- 云安全组没放行时,系统防火墙放行有没有意义?
+- 安全组放行了但 UFW 拒绝,会是什么表现?
+- 能否用一个最简流程快速判断问题在哪一层?
+
+现在新人常被“服务正常、端口不通”卡住,求一个固定排障模板。""",
+ "comments": [
+ ("sec_neo", "先看安全组再看系统防火墙,任意一层拒绝都不通。两层都要放行才行。"),
+ ("ops_alan", "建议固定三步:安全组 -> 防火墙 -> 服务监听,避免来回猜。"),
+ ],
+ "likes": ["sec_neo", "ops_alan", "dev_yuki"],
+ "bookmarks": ["ai_rookie", "cloud_nana", "linux_mason"],
+ },
+ {
+ "title": "绑定域名后总跳到 Nginx Welcome Page,server_name 应该怎么配?",
+ "author": "ai_rookie",
+ "category": "新手提问",
+ "days_ago": 0,
+ "content": """我已经把域名解析到了服务器,也有自己的站点配置,但访问域名总是落到默认欢迎页。
+
+我检查到:
+1. `/etc/nginx/sites-enabled` 里同时存在 default 和我的站点;
+2. 我的 server_name 只写了主域名,没写 www;
+3. 有多个配置都监听 80。
+
+请问正确做法是不是:
+- 删除/禁用默认站点;
+- server_name 同时写 `example.com` 和 `www.example.com`;
+- 用 `nginx -t` 检查冲突,再 reload。""",
+ "comments": [
+ ("linux_mason", "是的,默认站点优先命中非常常见。建议明确一个 default_server,其他按域名精确匹配。"),
+ ("cloud_nana", "别忘了 HTTPS 的 server block 也要同步配置,不然 443 还是会走错。"),
+ ],
+ "likes": ["linux_mason", "cloud_nana", "ops_alan"],
+ "bookmarks": ["dev_yuki", "ops_alan", "ai_rookie"],
+ },
+ {
+ "title": "OpenClaw 服务启动成功但页面空白,日志里没有明显报错怎么办?",
+ "author": "ai_rookie",
+ "category": "新手提问",
+ "days_ago": 0,
+ "content": """我按教程装了 OpenClaw,systemd 状态是 active,但前端页面空白或一直 loading。
+
+我目前想到的排查:
+1. 检查配置文件里 API 地址是否写错(内网/公网);
+2. 浏览器开发者工具看网络请求是否 4xx/5xx;
+3. 检查反向代理路径和 WebSocket 升级头;
+4. 看 openclaw 日志级别是否太低,临时改为 debug;
+5. 校验数据库连接和初始化状态。
+
+有没有人踩过类似坑,最后是哪里的问题?""",
+ "comments": [
+ ("dev_yuki", "优先看浏览器 network 面板,空白页大概率是静态资源 404 或 API 跨域。"),
+ ("sec_neo", "如果走反代,确认 `Upgrade`/`Connection` 头,WebSocket 缺这个会卡 loading。"),
+ ],
+ "likes": ["dev_yuki", "sec_neo", "cloud_nana"],
+ "bookmarks": ["ops_alan", "linux_mason", "ai_rookie"],
+ },
+ {
+ "title": "申请 Let's Encrypt 一直失败(too many failed authorizations),还能怎么处理?",
+ "author": "ai_rookie",
+ "category": "新手提问",
+ "days_ago": 0,
+ "content": """我在同一个域名上连续尝试了很多次证书申请,现在提示 `too many failed authorizations`。
+
+我理解是被限流了,但不确定下一步:
+1. 是不是要先等一段时间再重试?
+2. 失败期间怎么保证业务可访问(临时 HTTP / 自签证书)?
+3. 下次重试前要先验证哪些条件,避免再次失败?
+
+我现在的域名解析已经正常,80 端口也放开了。""",
+ "comments": [
+ ("cloud_nana", "先等限流窗口过去,再用 `--dry-run` 或 staging 环境验证流程,别直接打生产接口。"),
+ ("ops_alan", "重试前先用 HTTP 明确能访问 `/.well-known/acme-challenge/`,这一步最关键。"),
+ ],
+ "likes": ["cloud_nana", "ops_alan", "linux_mason"],
+ "bookmarks": ["dev_yuki", "ops_alan", "ai_rookie"],
+ },
+ {
+ "title": "宝塔用几天后磁盘爆满,新手该先清哪些目录?",
+ "author": "ai_rookie",
+ "category": "新手提问",
+ "days_ago": 0,
+ "content": """2C4G 小机器,刚装宝塔一周磁盘就 90% 了,怕直接删错文件。
+
+目前怀疑的占用来源:
+- Nginx / 应用日志;
+- 宝塔自动备份;
+- Docker 镜像与容器日志;
+- 数据库 binlog。
+
+有没有一个“安全清理顺序”?
+我希望先释放空间,后面再补定期清理策略。""",
+ "comments": [
+ ("ops_alan", "先 `du -sh /*` 定位大头,再按日志->备份->镜像顺序清理,不要盲删系统目录。"),
+ ("linux_mason", "Docker 场景补一句:`docker system df` 先看占用,再按策略 prune。"),
+ ],
+ "likes": ["ops_alan", "linux_mason", "sec_neo"],
+ "bookmarks": ["cloud_nana", "dev_yuki", "ai_rookie"],
+ },
+ {
+ "title": "新手配置选型:2核4G 能不能同时跑 Flask + MySQL + OpenClaw?",
+ "author": "ai_rookie",
+ "category": "新手提问",
+ "days_ago": 0,
+ "content": """预算有限,先买了 2核4G 机器,想跑:
+1. Flask 网站
+2. MySQL
+3. OpenClaw
+
+担心点:
+- 高峰时内存不够导致 OOM;
+- MySQL 占用太大拖慢接口;
+- 后续扩容迁移复杂。
+
+有没有比较稳妥的起步建议?例如哪些服务先拆、哪些参数先限制?""",
+ "comments": [
+ ("dev_yuki", "2核4G 能跑,但建议先把 MySQL 参数收紧,Gunicorn worker 不要开太多。"),
+ ("cloud_nana", "如果流量上来,优先把数据库拆到独立实例,应用层水平扩展更简单。"),
+ ("sec_neo", "别忘了开启 swap 兜底,但它只是缓冲,不能替代真正扩容。"),
+ ],
+ "likes": ["dev_yuki", "cloud_nana", "sec_neo", "ops_alan"],
+ "bookmarks": ["ops_alan", "linux_mason", "ai_rookie", "cloud_nana"],
+ },
+ {
+ "title": "OpenClaw 安装实战:从 0 到可用(官方安装页版)",
+ "author": "dev_yuki",
+ "category": "运维经验",
+ "days_ago": 0,
+ "is_featured": True,
+ "content": """这篇按官方安装页(https://openclaw.im/#install)整理,目标是:新手 10-20 分钟跑通。
+
+一、安装方式(推荐一键脚本)
+1. 服务器执行:
+ `curl -fsSL https://openclaw.im/install.sh | bash`
+2. 安装后检查:
+ `openclaw --version`
+
+如果你更习惯包管理器,也可以:
+- npm: `npm install -g openclaw@latest`
+- pnpm: `pnpm add -g openclaw@latest`
+
+二、初始化(关键步骤)
+1. 执行引导:
+ `openclaw onboard --install-daemon`
+2. 按提示完成 Provider、模型、存储等配置。
+3. 安装完成后,建议先跑健康检查:
+ `openclaw gateway status`
+
+三、Web 控制台与连接
+1. 默认控制台地址:
+ `http://127.0.0.1:18789/`
+2. 如果你是远程服务器,建议用 Nginx 反向代理并开启 HTTPS。
+3. 需要连接频道时,使用:
+ `openclaw channels login`
+
+四、Docker 部署(可选)
+官方也提供 Docker 工作流,常见顺序:
+1. `./docker-setup.sh`
+2. `docker compose run --rm openclaw-cli onboard`
+3. `docker compose up -d openclaw-gateway`
+
+五、排错清单(最常见)
+1. 命令执行失败:先检查 Node 版本(官方建议 Node >= 22)。
+2. 页面打不开:确认 18789 端口监听与防火墙/安全组放行。
+3. 网关状态异常:先看 `openclaw gateway status`,再复查 onboard 配置。
+4. 远程访问不稳定:优先通过反向代理统一入口,不直接暴露高风险端口。
+
+六、上线建议
+1. 把配置与日志目录分离,方便升级和回滚。
+2. 使用守护方式运行(onboard 的 daemon 选项),避免进程意外退出。
+3. 做最小暴露:仅开放必要端口,后台入口加访问控制。""",
+ "comments": [
+ ("ops_alan", "这篇很适合新手,先一键安装再做反代是比较稳的路径。"),
+ ("sec_neo", "建议补一句:公网部署务必加 HTTPS 和访问控制,别裸奔。"),
+ ("ai_rookie", "我刚按这个流程跑通了,`openclaw gateway status` 这个检查很有用。"),
+ ],
+ "likes": ["ops_alan", "cloud_nana", "linux_mason", "sec_neo", "ai_rookie"],
+ "bookmarks": ["ops_alan", "cloud_nana", "ai_rookie"],
+ },
+]
+
+
+def _utcnow_naive():
+ """兼容 Python 3.13:返回无时区 UTC 时间。"""
+ return datetime.now(timezone.utc).replace(tzinfo=None)
+
+
+def _resolve_category(name):
+ if not name:
+ return "综合讨论"
+ row = ForumCategory.query.filter_by(name=name).first()
+ if row:
+ return row.name
+ active = ForumCategory.query.filter_by(is_active=True).order_by(ForumCategory.sort_order.asc()).first()
+ if active:
+ return active.name
+ any_row = ForumCategory.query.order_by(ForumCategory.sort_order.asc()).first()
+ if any_row:
+ return any_row.name
+ return "综合讨论"
+
+
+def _get_or_create_user(username, password):
+ user = User.query.filter_by(username=username).first()
+ created = False
+ if user is None:
+ user = User(username=username)
+ user.set_password(password)
+ user.last_login_at = _utcnow_naive()
+ db.session.add(user)
+ db.session.flush()
+ created = True
+ return user, created
+
+
+def main():
+ created_users = 0
+ skipped_users = 0
+ created_posts = 0
+ skipped_posts = 0
+ created_comments = 0
+ created_likes = 0
+ created_bookmarks = 0
+
+ with app.app_context():
+ user_map = {}
+ for u in USERS:
+ user, created = _get_or_create_user(u["username"], u["password"])
+ user_map[u["username"]] = user
+ if created:
+ created_users += 1
+ else:
+ skipped_users += 1
+
+ db.session.flush()
+
+ for idx, spec in enumerate(POSTS):
+ title = spec["title"].strip()
+ author = user_map.get(spec["author"])
+ if not author:
+ continue
+
+ exists = ForumPost.query.filter_by(title=title).first()
+ if exists:
+ skipped_posts += 1
+ continue
+
+ created_at = _utcnow_naive() - timedelta(days=int(spec.get("days_ago", 0)), hours=max(0, idx))
+ post = ForumPost(
+ user_id=author.id,
+ category=_resolve_category(spec.get("category")),
+ title=title,
+ content=spec["content"].strip(),
+ is_pinned=bool(spec.get("is_pinned")),
+ is_featured=bool(spec.get("is_featured")),
+ is_locked=bool(spec.get("is_locked")),
+ view_count=120 + idx * 19,
+ created_at=created_at,
+ updated_at=created_at,
+ )
+ db.session.add(post)
+ db.session.flush()
+ created_posts += 1
+
+ for c_idx, (comment_user, comment_text) in enumerate(spec.get("comments", [])):
+ c_user = user_map.get(comment_user)
+ if not c_user:
+ continue
+ comment_at = created_at + timedelta(hours=2 + c_idx * 3)
+ db.session.add(ForumComment(
+ post_id=post.id,
+ user_id=c_user.id,
+ content=comment_text.strip(),
+ created_at=comment_at,
+ updated_at=comment_at,
+ ))
+ created_comments += 1
+
+ for like_user in spec.get("likes", []):
+ l_user = user_map.get(like_user)
+ if not l_user:
+ continue
+ db.session.add(ForumPostLike(post_id=post.id, user_id=l_user.id))
+ created_likes += 1
+
+ for bookmark_user in spec.get("bookmarks", []):
+ b_user = user_map.get(bookmark_user)
+ if not b_user:
+ continue
+ db.session.add(ForumPostBookmark(post_id=post.id, user_id=b_user.id))
+ created_bookmarks += 1
+
+ db.session.commit()
+
+ print("用户:新增 {},已存在 {}".format(created_users, skipped_users))
+ print("帖子:新增 {},已存在 {}".format(created_posts, skipped_posts))
+ print("评论:新增 {}".format(created_comments))
+ print("点赞:新增 {}".format(created_likes))
+ print("收藏:新增 {}".format(created_bookmarks))
+ print("默认测试密码(新用户):{}".format(DEFAULT_PASSWORD))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/static/css/forum.css b/static/css/forum.css
index 0d4952d..a78421e 100644
--- a/static/css/forum.css
+++ b/static/css/forum.css
@@ -41,17 +41,27 @@
}
.forum-logo {
- font-family: var(--font-mono);
- font-size: 1.5rem;
- font-weight: 700;
- margin: 0;
- background: linear-gradient(135deg, #0F172A 0%, var(--accent) 100%);
- -webkit-background-clip: text;
- -webkit-text-fill-color: transparent;
- background-clip: text;
- letter-spacing: -0.02em;
- display: inline-block;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
text-decoration: none;
+ color: var(--text);
+ min-width: 0;
+}
+
+.forum-logo img {
+ width: 34px;
+ height: 34px;
+ border-radius: 9px;
+ flex-shrink: 0;
+}
+
+.forum-logo span {
+ font-family: var(--font-mono);
+ font-size: 1.05rem;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+ white-space: nowrap;
}
.forum-primary-nav {
@@ -306,6 +316,10 @@
font-family: var(--font-mono);
}
+.topic-col-mini {
+ text-align: center;
+}
+
.topic-list {
list-style: none;
margin: 0;
@@ -313,13 +327,45 @@
}
.topic-result {
- padding: 0.5rem 0.92rem;
color: var(--text-muted);
font-size: 0.78rem;
- border-bottom: 1px solid var(--border);
+}
+
+.topic-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.58rem 0.92rem;
+ border-top: 1px solid var(--border);
background: var(--bg-card);
}
+.page-size-form {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: 0.78rem;
+ color: var(--text-muted);
+}
+
+.page-size-form select {
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-elevated);
+ color: var(--text);
+ font-size: 0.82rem;
+ line-height: 1.2;
+ padding: 0.26rem 0.42rem;
+ cursor: pointer;
+}
+
+.page-size-form select:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-glow);
+}
+
.topic-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 90px 90px 110px;
@@ -327,6 +373,7 @@
min-height: 74px;
border-bottom: 1px solid var(--border);
transition: var(--transition);
+ padding: 0 0.92rem;
}
.topic-row:last-child {
@@ -342,7 +389,7 @@
align-items: center;
gap: 0.68rem;
min-width: 0;
- padding: 0.68rem 0.95rem;
+ padding: 0.68rem 0;
}
.topic-avatar {
@@ -610,7 +657,7 @@
}
.topic-post-content {
- white-space: pre-wrap;
+ white-space: normal;
color: var(--text);
line-height: 1.66;
font-size: 0.95rem;
@@ -629,6 +676,13 @@
font-size: 0.82rem;
}
+.form-help {
+ display: block;
+ margin-top: 0.35rem;
+ color: var(--text-muted);
+ font-size: 0.76rem;
+}
+
.comment-form textarea,
.post-form input,
.post-form textarea,
@@ -715,13 +769,74 @@
}
.comment-content {
- white-space: pre-wrap;
+ white-space: normal;
margin-top: 0.32rem;
color: var(--text);
line-height: 1.58;
font-size: 0.9rem;
}
+.md-content p {
+ margin: 0 0 0.7rem;
+}
+
+.md-content p:last-child {
+ margin-bottom: 0;
+}
+
+.md-content ul,
+.md-content ol {
+ margin: 0.25rem 0 0.75rem 1.25rem;
+ padding: 0;
+}
+
+.md-content li + li {
+ margin-top: 0.22rem;
+}
+
+.md-content blockquote {
+ margin: 0.75rem 0;
+ padding: 0.45rem 0.8rem;
+ border-left: 3px solid var(--accent);
+ background: var(--accent-glow);
+ color: var(--text-muted);
+}
+
+.md-content a {
+ color: var(--accent);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+.md-content pre {
+ margin: 0.75rem 0;
+ padding: 0.72rem 0.82rem;
+ border-radius: 10px;
+ border: 1px solid var(--border);
+ background: #0f172a;
+ color: #e2e8f0;
+ overflow-x: auto;
+ white-space: pre;
+ line-height: 1.45;
+}
+
+.md-content code {
+ font-family: var(--font-mono);
+ font-size: 0.86em;
+ background: rgba(2, 132, 199, 0.12);
+ border: 1px solid rgba(2, 132, 199, 0.18);
+ border-radius: 5px;
+ padding: 0.08rem 0.32rem;
+}
+
+.md-content pre code {
+ background: transparent;
+ border: none;
+ border-radius: 0;
+ padding: 0;
+ color: inherit;
+}
+
.comment-actions {
margin-top: 0.44rem;
display: flex;
@@ -1031,8 +1146,13 @@
justify-content: flex-start;
}
- .forum-logo {
- font-size: 1.5rem;
+ .forum-logo img {
+ width: 30px;
+ height: 30px;
+ }
+
+ .forum-logo span {
+ font-size: 0.96rem;
}
.forum-shell {
@@ -1057,6 +1177,11 @@
width: 100%;
}
+ .topic-footer {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
.topic-head {
grid-template-columns: minmax(0, 1fr) 64px 64px;
}
diff --git a/static/css/style.css b/static/css/style.css
index bae6c64..ecbb76c 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -73,6 +73,18 @@ body {
gap: 0.75rem;
}
+.site-logo-link {
+ display: inline-flex;
+ align-items: center;
+ text-decoration: none;
+}
+
+.site-logo {
+ display: block;
+ width: clamp(168px, 24vw, 240px);
+ height: auto;
+}
+
.logo {
font-family: var(--font-mono);
font-size: 1.5rem;
@@ -117,24 +129,34 @@ body {
align-items: center;
gap: 0.25rem;
margin-right: 0.5rem;
- font-size: 0.9rem;
+ font-size: 0.875rem;
+ background: var(--bg-elevated);
+ padding: 0.25rem;
+ border-radius: 6px;
+ border: 1px solid var(--border);
}
.lang-switch a {
- color: var(--accent);
+ color: var(--text-muted);
text-decoration: none;
- padding: 0.2rem 0.4rem;
+ padding: 0.3rem 0.6rem;
border-radius: 4px;
+ transition: var(--transition);
+ font-weight: 500;
}
.lang-switch a:hover {
- background: var(--accent-glow);
+ color: var(--accent);
+ background: var(--bg-card);
}
.lang-switch a.active {
font-weight: 600;
color: var(--text);
+ background: var(--bg-card);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.lang-sep {
- color: var(--text-muted);
+ color: var(--border);
user-select: none;
+ font-weight: 300;
}
.nav-link-with-badge {
@@ -181,13 +203,13 @@ body {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+ box-shadow: var(--shadow);
transition: var(--transition);
}
.filters:hover {
border-color: var(--border-hover);
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ box-shadow: var(--shadow-lg);
}
.filter-group {
@@ -378,6 +400,21 @@ body {
color: var(--text-muted);
}
+/* 加载动画 */
+.loading-spinner {
+ display: inline-block;
+ width: 40px;
+ height: 40px;
+ border: 3px solid var(--border);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
.footer {
margin-top: auto;
padding: 1rem 1.5rem;
@@ -462,12 +499,13 @@ body {
@media (max-width: 768px) {
.header {
- padding: 1.5rem 1rem;
+ padding: 1rem;
}
.header-inner {
flex-direction: column;
align-items: flex-start;
+ gap: 0.75rem;
}
.header-brand {
@@ -478,10 +516,29 @@ body {
.header-nav {
width: 100%;
justify-content: flex-start;
+ font-size: 0.85rem;
+ gap: 0.5rem;
+ }
+
+ .lang-switch {
+ font-size: 0.8rem;
+ padding: 0.2rem;
+ }
+
+ .lang-switch a {
+ padding: 0.25rem 0.5rem;
}
.logo {
- font-size: 1.5rem;
+ font-size: 1.25rem;
+ }
+
+ .site-logo {
+ width: clamp(148px, 44vw, 210px);
+ }
+
+ .tagline {
+ font-size: 0.8rem;
}
.filters {
@@ -490,6 +547,14 @@ body {
padding: 1rem;
}
+ .filter-group {
+ min-width: 100%;
+ }
+
+ .filter-group select {
+ width: 100%;
+ }
+
.btn-reset {
margin-left: 0;
width: 100%;
@@ -543,7 +608,7 @@ html {
}
.filter-group-search input {
- font-family: var(--font-mono);
+ font-family: var(--font-sans);
font-size: 0.9rem;
padding: 0.5rem 0.75rem;
width: 100%;
@@ -554,6 +619,10 @@ html {
transition: var(--transition);
}
+.filter-group-search input:hover {
+ border-color: var(--accent);
+}
+
.filter-group-search input:focus {
outline: none;
border-color: var(--accent);
@@ -562,6 +631,7 @@ html {
.filter-group-search input::placeholder {
color: var(--text-muted);
+ opacity: 0.7;
}
/* 可排序表头 */
diff --git a/static/img/site-logo-mark.svg b/static/img/site-logo-mark.svg
new file mode 100644
index 0000000..9d59d8e
--- /dev/null
+++ b/static/img/site-logo-mark.svg
@@ -0,0 +1,24 @@
+
+ VPS Price Mark
+ A square icon with stacked server bars and upward trend arrow.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/img/site-logo.svg b/static/img/site-logo.svg
new file mode 100644
index 0000000..bd4e32d
--- /dev/null
+++ b/static/img/site-logo.svg
@@ -0,0 +1,31 @@
+
+ VPS Price Logo
+ A modern cloud server price comparison logo with a stacked server icon and upward trend arrow.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ VPS Price
+ FORUM & COMPARE
+
+
diff --git a/static/js/main-simple.js b/static/js/main-simple.js
index 685b269..7ba8876 100644
--- a/static/js/main-simple.js
+++ b/static/js/main-simple.js
@@ -293,6 +293,8 @@
var currentPrice = getPriceValue(plan, filters.currency);
var displayPrice = currentPrice ? currentPrice.symbol + currentPrice.value.toFixed(2) : '—';
+ var btnText = (window.I18N_JS && window.I18N_JS.btn_visit) || '访问';
+
tr.innerHTML =
'' + escapeHtml(plan.provider) + ' ' +
'' + escapeHtml(plan.countries) + ' ' +
@@ -304,7 +306,7 @@
'' + plan.traffic + ' ' +
'' + displayPrice + ' ' +
'' +
- '访问 ' +
+ '' + btnText + ' ' +
' ';
return tr;
diff --git a/templates/auth/login.html b/templates/auth/login.html
index 0a37330..2b41af3 100644
--- a/templates/auth/login.html
+++ b/templates/auth/login.html
@@ -1,9 +1,10 @@
-
+
- 用户登录 - 云价眼
+ {{ l('用户登录', 'Login') }} - {{ l('云价眼', 'VPS Price') }}
+
@@ -11,33 +12,43 @@
- 登录账号
+ {{ l('登录账号', 'Sign In') }}
{% if error %}
{{ error }}
{% endif %}
diff --git a/templates/auth/register.html b/templates/auth/register.html
index 9446639..a39f980 100644
--- a/templates/auth/register.html
+++ b/templates/auth/register.html
@@ -1,9 +1,10 @@
-
+
- 用户注册 - 云价眼
+ {{ l('用户注册', 'Register') }} - {{ l('云价眼', 'VPS Price') }}
+
@@ -11,37 +12,47 @@
- 注册账号
+ {{ l('注册账号', 'Create Account') }}
{% if error %}
{{ error }}
{% endif %}
diff --git a/templates/forum/comment_form.html b/templates/forum/comment_form.html
index 66c4281..a6d7ba7 100644
--- a/templates/forum/comment_form.html
+++ b/templates/forum/comment_form.html
@@ -1,9 +1,10 @@
-
+
- 编辑评论 - 论坛
+ {{ l('编辑评论', 'Edit Comment') }} - {{ l('论坛', 'Forum') }}
+
@@ -11,35 +12,44 @@
- 编辑评论
+ {{ l('编辑评论', 'Edit Comment') }}
{% if error %}
{{ error }}
{% endif %}
diff --git a/templates/forum/index.html b/templates/forum/index.html
index d72f45f..75a46f2 100644
--- a/templates/forum/index.html
+++ b/templates/forum/index.html
@@ -1,9 +1,10 @@
-
+
- 论坛 - 云价眼
+ {{ l('论坛', 'Forum') }} - {{ l('云价眼', 'VPS Price') }}
+
@@ -13,24 +14,32 @@