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 %}
- +
- +
- - 去注册 + + {{ l('去注册', 'Create Account') }}
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 %}
- - + +
- +
- +
- - 去登录 + + {{ l('去登录', 'Go to Login') }}
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 %}
- + + {{ l('支持 Markdown 代码块。', 'Markdown code blocks are supported.') }}
- - 取消 + + {{ l('取消', 'Cancel') }}
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 @@
@@ -42,15 +51,15 @@ {% for item in tab_links %} {{ item.label }} {% endfor %} - 分类 + {{ l('分类', 'Categories') }}
{% if current_user and not current_user.is_banned %} - + 发布主题 + + {{ l('发布主题', 'New Topic') }} {% elif current_user and current_user.is_banned %} - 账号封禁中 + {{ l('账号封禁中', 'Account banned') }} {% else %} - 登录后发帖 + {{ l('登录后发帖', 'Login to post') }} {% endif %}
@@ -64,16 +73,17 @@
+ {% if selected_category %} {% endif %} - - + + {% if search_query %} - 清空搜索 + {{ l('清空搜索', 'Clear Search') }} {% endif %} {% if has_filters %} - 重置全部 + {{ l('重置全部', 'Reset All') }} {% endif %}
@@ -91,14 +101,11 @@
-
主题
-
回复
-
浏览
-
活动
+
{{ l('主题', 'Topic') }}
+
{{ l('回复', 'Replies') }}
+
{{ l('浏览', 'Views') }}
+
{{ l('活动', 'Activity') }}
- {% if total_posts %} -
显示 {{ result_start }} - {{ result_end }} / 共 {{ total_posts }} 条
- {% endif %} {% if cards %}
@@ -130,38 +137,66 @@ {% endfor %} {% if total_pages > 1 %} -
diff --git a/templates/forum/notifications.html b/templates/forum/notifications.html index b52cca3..3e23212 100644 --- a/templates/forum/notifications.html +++ b/templates/forum/notifications.html @@ -1,9 +1,10 @@ - + - 通知中心 - 论坛 + {{ l('通知中心', 'Notifications') }} - {{ l('论坛', 'Forum') }} + @@ -11,17 +12,25 @@
@@ -30,25 +39,25 @@
-

通知中心

-

查看别人对你帖子/评论的互动,以及举报处理结果。

+

{{ l('通知中心', 'Notifications') }}

+

{{ l('查看别人对你帖子/评论的互动,以及举报处理结果。', 'Track interactions on your posts/comments and report outcomes.') }}

-
总通知{{ total_count }}
-
未读{{ unread_count }}
-
已读{{ read_count }}
-
当前筛选{{ '未读' if active_status == 'unread' else '已读' if active_status == 'read' else '全部' }}
+
{{ l('总通知', 'Total') }}{{ total_count }}
+
{{ l('未读', 'Unread') }}{{ unread_count }}
+
{{ l('已读', 'Read') }}{{ read_count }}
+
{{ l('当前筛选', 'Current Filter') }}{{ l('未读', 'Unread') if active_status == 'unread' else l('已读', 'Read') if active_status == 'read' else l('全部', 'All') }}
- +
@@ -66,21 +75,21 @@
  • - {{ '未读' if not n.is_read else '已读' }} + {{ l('未读', 'Unread') if not n.is_read else l('已读', 'Read') }} {{ item.type_label }} {{ item.time_text }} {% if item.actor_name %} - 来自 {{ item.actor_name }} + {{ l('来自', 'From') }} {{ item.actor_name }} {% endif %}
    {{ n.message }}
    - 查看 + {{ l('查看', 'Open') }} {% if not n.is_read %}
    - +
    {% endif %}
    @@ -88,27 +97,27 @@ {% endfor %} {% else %} -

    当前没有通知。

    +

    {{ l('当前没有通知。', 'No notifications right now.') }}

    {% endif %}
  • diff --git a/templates/forum/post_detail.html b/templates/forum/post_detail.html index 09b5191..9cc3449 100644 --- a/templates/forum/post_detail.html +++ b/templates/forum/post_detail.html @@ -1,9 +1,10 @@ - + - {{ post.title }} - 论坛 + {{ post.title }} - {{ l('论坛', 'Forum') }} + @@ -12,24 +13,32 @@
    + + 中文 + | + English + {% if current_user %} - {{ current_user.username }}{% if current_user.is_banned %}(封禁){% endif %} - 个人中心 - 通知{% if notifications_unread_count %}{{ notifications_unread_count }}{% endif %} - 退出 + {{ current_user.username }}{% if current_user.is_banned %}{{ l('(封禁)', ' (Banned)') }}{% endif %} + {{ l('个人中心', 'Profile') }} + {{ l('通知', 'Notifications') }}{% if notifications_unread_count %}{{ notifications_unread_count }}{% endif %} + {{ l('退出', 'Logout') }} {% else %} - 登录 - 注册 + {{ l('登录', 'Login') }} + {{ l('注册', 'Register') }} {% endif %}
    @@ -40,37 +49,37 @@
    - {{ post.category or '综合讨论' }} - {% if post.is_pinned %}置顶{% endif %} - {% if post.is_featured %}精华{% endif %} - {% if post.is_locked %}锁帖{% endif %} + {{ post.category or l('综合讨论', 'General') }} + {% if post.is_pinned %}{{ l('置顶', 'Pinned') }}{% endif %} + {% if post.is_featured %}{{ l('精华', 'Featured') }}{% endif %} + {% if post.is_locked %}{{ l('锁帖', 'Locked') }}{% endif %} {{ post.created_at.strftime('%Y-%m-%d %H:%M') if post.created_at else '' }} - 浏览 {{ post.view_count or 0 }} - 点赞 {{ like_count or 0 }} - 收藏 {{ bookmark_count or 0 }} + {{ l('浏览', 'Views') }} {{ post.view_count or 0 }} + {{ l('点赞', 'Likes') }} {{ like_count or 0 }} + {{ l('收藏', 'Bookmarks') }} {{ bookmark_count or 0 }}

    {{ post.title }}

    - +
    {% if current_user and can_interact %}
    - +
    - +
    {% elif current_user and not can_interact %} - 账号被封禁,暂不可互动 + {{ l('账号被封禁,暂不可互动', 'Your account is banned. Interaction is disabled.') }} {% else %} - 登录后点赞/收藏 + {{ l('登录后点赞/收藏', 'Login to like/bookmark') }} {% endif %} {% if current_user and current_user.id == post.user_id and can_interact %} - 编辑帖子 -
    - + {{ l('编辑帖子', 'Edit Topic') }} + +
    {% elif current_user and current_user.id == post.user_id and not can_interact %} - 账号封禁中,无法编辑或删除帖子 + {{ l('账号封禁中,无法编辑或删除帖子', 'Account banned. Editing/deleting is disabled.') }} {% elif current_user and can_interact %}
    @@ -80,15 +89,15 @@ {% endfor %} - +
    {% endif %}
    -
    {{ post.content }}
    +
    {{ post.content|markdown_html }}
    -

    评论({{ comments|length }})

    +

    {{ l('评论', 'Comments') }}({{ comments|length }})

    {% if message %}

    {{ message }}

    {% endif %} @@ -97,19 +106,20 @@ {% endif %} {% if post.is_locked %} -

    该帖子已锁定,暂不允许新增评论。

    +

    {{ l('该帖子已锁定,暂不允许新增评论。', 'This topic is locked. New comments are disabled.') }}

    {% elif current_user and can_interact %}
    - + + {{ l('支持 Markdown 代码块。', 'Markdown code blocks are supported.') }}
    - +
    {% elif current_user and not can_interact %} -

    账号被封禁,暂不可评论。

    +

    {{ l('账号被封禁,暂不可评论。', 'Your account is banned. Commenting is disabled.') }}

    {% else %} -

    请先 登录 后评论。

    +

    {{ l('请先', 'Please') }} {{ l('登录', 'log in') }} {{ l('后评论。', 'to comment.') }}

    {% endif %} {% if comments %} @@ -119,18 +129,18 @@
    {{ (c.author_rel.username[0] if c.author_rel and c.author_rel.username else '?')|upper }}
    - {{ c.author_rel.username if c.author_rel else '已注销用户' }} + {{ c.author_rel.username if c.author_rel else l('已注销用户', 'Deleted user') }} {{ c.created_at.strftime('%Y-%m-%d %H:%M') if c.created_at else '' }}
    -
    {{ c.content }}
    +
    {{ c.content|markdown_html }}
    {% if current_user and current_user.id == c.user_id and can_interact %} - 编辑 -
    - + {{ l('编辑', 'Edit') }} + +
    {% elif current_user and current_user.id == c.user_id and not can_interact %} - 账号封禁中 + {{ l('账号封禁中', 'Account banned') }} {% elif current_user and can_interact %}
    @@ -140,7 +150,7 @@ {% endfor %} - +
    {% endif %}
    @@ -149,22 +159,22 @@ {% endfor %} {% else %} -

    还没有评论,欢迎抢沙发。

    +

    {{ l('还没有评论,欢迎抢沙发。', 'No comments yet. Be the first to reply.') }}

    {% endif %}
    diff --git a/templates/forum/post_form.html b/templates/forum/post_form.html index df60157..0d57325 100644 --- a/templates/forum/post_form.html +++ b/templates/forum/post_form.html @@ -1,33 +1,42 @@ - + - 发布帖子 - 云价眼 + {{ l('发布帖子', 'Topic Editor') }} - {{ l('云价眼', 'VPS Price') }} + {% set category_options = categories if categories is defined and categories else forum_categories if forum_categories is defined and forum_categories else ['综合讨论', 'VPS 评测', '优惠活动', '运维经验', '新手提问'] %} - {% set p_title = page_title if page_title is defined and page_title else '创建新主题' %} - {% set p_submit = submit_text if submit_text is defined and submit_text else '发布主题' %} + {% set p_title = page_title if page_title is defined and page_title else l('创建新主题', 'Create Topic') %} + {% set p_submit = submit_text if submit_text is defined and submit_text else l('发布主题', 'Publish') %} {% set p_action = action_url if action_url is defined and action_url else url_for('forum_post_new') %} {% set p_cancel = cancel_url if cancel_url is defined and cancel_url else url_for('forum_index') %} {% set p_mode = form_mode if form_mode is defined else 'create' %}
    @@ -38,9 +47,9 @@

    {{ p_title }}

    {% if p_mode == 'edit' %} - 修改标题、分类或正文后保存,帖子会按最新活动时间排序。 + {{ l('修改标题、分类或正文后保存,帖子会按最新活动时间排序。', 'Update title/category/content and save. The topic will be sorted by latest activity.') }} {% else %} - 描述你的问题、评测或优惠信息,方便其他用户快速理解。 + {{ l('描述你的问题、评测或优惠信息,方便其他用户快速理解。', 'Describe your question, review, or deal details for other users.') }} {% endif %}

    {% if error %} @@ -48,7 +57,7 @@ {% endif %}
    - +
    - - + +
    - - + + + {{ l('支持 Markdown,代码块可用 ```language ... ```', 'Markdown supported. Use ```language ... ``` for code blocks.') }}
    - 取消 + {{ l('取消', 'Cancel') }}
    diff --git a/templates/forum/profile.html b/templates/forum/profile.html index e61ea04..e799c9e 100644 --- a/templates/forum/profile.html +++ b/templates/forum/profile.html @@ -1,9 +1,10 @@ - + - 个人中心 - 论坛 + {{ l('个人中心', 'Profile') }} - {{ l('论坛', 'Forum') }} + @@ -11,17 +12,25 @@
    @@ -30,27 +39,27 @@
    -

    个人中心

    -

    管理你的帖子、评论和账号设置。

    +

    {{ l('个人中心', 'Profile') }}

    +

    {{ l('管理你的帖子、评论和账号设置。', 'Manage your topics, comments, and account settings.') }}

    -
    帖子{{ stats.post_count }}
    -
    评论{{ stats.comment_count }}
    -
    点赞{{ stats.like_count }}
    -
    收藏{{ stats.bookmark_count }}
    -
    举报{{ stats.report_count }}
    -
    待处理举报{{ stats.pending_report_count }}
    -
    通知{{ stats.notification_count }}
    -
    未读通知{{ stats.unread_notification_count }}
    +
    {{ l('帖子', 'Posts') }}{{ stats.post_count }}
    +
    {{ l('评论', 'Comments') }}{{ stats.comment_count }}
    +
    {{ l('点赞', 'Likes') }}{{ stats.like_count }}
    +
    {{ l('收藏', 'Bookmarks') }}{{ stats.bookmark_count }}
    +
    {{ l('举报', 'Reports') }}{{ stats.report_count }}
    +
    {{ l('待处理举报', 'Pending Reports') }}{{ stats.pending_report_count }}
    +
    {{ l('通知', 'Notifications') }}{{ stats.notification_count }}
    +
    {{ l('未读通知', 'Unread') }}{{ stats.unread_notification_count }}
    {% if message %} @@ -69,23 +78,23 @@
    {{ post.title }}
    - {{ post.category or '综合讨论' }} - 创建:{{ post.created_at.strftime('%Y-%m-%d %H:%M') if post.created_at else '' }} - 回复 {{ item.reply_count }} - 浏览 {{ item.view_count }} + {{ post.category or l('综合讨论', 'General') }} + {{ l('创建:', 'Created: ') }}{{ post.created_at.strftime('%Y-%m-%d %H:%M') if post.created_at else '' }} + {{ l('回复', 'Replies') }} {{ item.reply_count }} + {{ l('浏览', 'Views') }} {{ item.view_count }}
    - 编辑 -
    - + {{ l('编辑', 'Edit') }} + +
    {% endfor %} {% else %} -

    你还没有发过帖子,先去 发布主题 吧。

    +

    {{ l('你还没有发过帖子,先去', 'You have not posted yet. Go') }} {{ l('发布主题', 'create one') }}{{ l('吧。', '.') }}

    {% endif %} {% elif active_tab == 'comments' %} {% if my_comment_items %} @@ -94,23 +103,23 @@ {% set c = item.comment %}
  • - {{ item.post_title or '帖子已删除' }} + {{ item.post_title or l('帖子已删除', 'Deleted topic') }}
    - 评论时间:{{ c.created_at.strftime('%Y-%m-%d %H:%M') if c.created_at else '' }} + {{ l('评论时间:', 'Commented: ') }}{{ c.created_at.strftime('%Y-%m-%d %H:%M') if c.created_at else '' }}
    -
    {{ c.content }}
    +
    {{ c.content|markdown_html }}
    - 编辑 -
    - + {{ l('编辑', 'Edit') }} + +
  • {% endfor %} {% else %} -

    你还没有评论记录。

    +

    {{ l('你还没有评论记录。', 'No comments yet.') }}

    {% endif %} {% elif active_tab == 'likes' %} {% if my_like_items %} @@ -120,22 +129,22 @@
    {{ item.post_title }}
    - {{ item.post_category or '综合讨论' }} - 帖子创建:{{ item.post_created_at.strftime('%Y-%m-%d %H:%M') if item.post_created_at else '' }} - 点赞时间:{{ item.like.created_at.strftime('%Y-%m-%d %H:%M') if item.like.created_at else '' }} + {{ item.post_category or l('综合讨论', 'General') }} + {{ l('帖子创建:', 'Post created: ') }}{{ item.post_created_at.strftime('%Y-%m-%d %H:%M') if item.post_created_at else '' }} + {{ l('点赞时间:', 'Liked at: ') }}{{ item.like.created_at.strftime('%Y-%m-%d %H:%M') if item.like.created_at else '' }}
    - - + +
    {% endfor %} {% else %} -

    你还没有点赞任何帖子。

    +

    {{ l('你还没有点赞任何帖子。', 'You have not liked any topics yet.') }}

    {% endif %} {% elif active_tab == 'bookmarks' %} {% if my_bookmark_items %} @@ -145,54 +154,54 @@
    {{ item.post_title }}
    - {{ item.post_category or '综合讨论' }} - 帖子创建:{{ item.post_created_at.strftime('%Y-%m-%d %H:%M') if item.post_created_at else '' }} - 收藏时间:{{ item.bookmark.created_at.strftime('%Y-%m-%d %H:%M') if item.bookmark.created_at else '' }} + {{ item.post_category or l('综合讨论', 'General') }} + {{ l('帖子创建:', 'Post created: ') }}{{ item.post_created_at.strftime('%Y-%m-%d %H:%M') if item.post_created_at else '' }} + {{ l('收藏时间:', 'Bookmarked at: ') }}{{ item.bookmark.created_at.strftime('%Y-%m-%d %H:%M') if item.bookmark.created_at else '' }}
    - - + +
    {% endfor %} {% else %} -

    你还没有收藏任何帖子。

    +

    {{ l('你还没有收藏任何帖子。', 'You have not bookmarked any topics yet.') }}

    {% endif %} {% else %}
    -

    基础资料

    +

    {{ l('基础资料', 'Profile Info') }}

    - +
    - +
    -

    修改密码

    +

    {{ l('修改密码', 'Change Password') }}

    - +
    - +
    - +
    - +
    @@ -202,19 +211,19 @@ diff --git a/templates/index.html b/templates/index.html index 757ffba..982b0dc 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,7 +3,8 @@ - {{ site_name }} - 阿里云/腾讯云/DigitalOcean/Vultr 等 VPS 月付价格对比 + {{ site_name }} - Global VPS Price Comparison + @@ -42,24 +43,26 @@
    @@ -171,7 +174,7 @@ - + @@ -181,7 +184,8 @@ window.LANG = {{ lang|tojson }}; window.I18N_JS = { empty_state: {{ t.empty_state|tojson }}, - load_error: {{ t.load_error|tojson }} + load_error: {{ t.load_error|tojson }}, + btn_visit: {{ ('访问' if lang == 'zh' else 'Visit')|tojson }} };