This commit is contained in:
ddrwode
2026-02-10 13:48:58 +08:00
parent d29515598d
commit 036a19f28c
17 changed files with 1620 additions and 357 deletions

376
app.py
View File

@@ -1,11 +1,22 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""云服务器价格对比 - Flask 应用""" """云服务器价格对比 - Flask 应用"""
import io import io
from time import monotonic
from datetime import datetime, timezone from datetime import datetime, timezone
from urllib.parse import urlencode from urllib.parse import urlencode
from flask import Flask, render_template, jsonify, request, redirect, url_for, session, send_file from flask import Flask, render_template, jsonify, request, redirect, url_for, session, send_file
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from sqlalchemy import text, func, or_ 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 config import Config
from extensions import db from extensions import db
from openpyxl import Workbook 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("<p>{}</p>".format(str(escape(raw)).replace("\n", "<br>")))
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(): def _get_current_user():
user_id = session.get("user_id") user_id = session.get("user_id")
if not user_id: if not user_id:
@@ -292,10 +419,9 @@ def _create_notification(
def _notification_target_url(notification): def _notification_target_url(notification):
# 避免通知列表页按条检查帖子存在性导致 N+1 查询。
if notification.post_id: if notification.post_id:
exists = db.session.get(ForumPost, notification.post_id) return url_for("forum_post_detail", post_id=notification.post_id)
if exists:
return url_for("forum_post_detail", post_id=notification.post_id)
return url_for("user_notifications") return url_for("user_notifications")
@@ -311,21 +437,32 @@ def _load_forum_categories(active_only=True):
def _get_forum_category_names(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) rows = _load_forum_categories(active_only=active_only)
names = [x.name for x in rows if x.name] names = [x.name for x in rows if x.name]
if names: if names:
_FORUM_CATEGORY_CACHE[cache_key] = (now_ts + _FORUM_CACHE_TTL_CATEGORIES, tuple(names))
return names return names
# 若全部被停用,前台仍回退到已存在分类,避免下拉为空。 # 若全部被停用,前台仍回退到已存在分类,避免下拉为空。
if active_only: if active_only:
rows = _load_forum_categories(active_only=False) rows = _load_forum_categories(active_only=False)
names = [x.name for x in rows if x.name] names = [x.name for x in rows if x.name]
if names: if names:
_FORUM_CATEGORY_CACHE[cache_key] = (now_ts + _FORUM_CACHE_TTL_CATEGORIES, tuple(names))
return 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 @app.context_processor
def inject_global_user(): def inject_global_user():
lang = _get_lang()
current_user = _get_current_user() current_user = _get_current_user()
notifications_unread_count = 0 notifications_unread_count = 0
if current_user: if current_user:
@@ -339,12 +476,14 @@ def inject_global_user():
"forum_categories": _get_forum_category_names(active_only=True), "forum_categories": _get_forum_category_names(active_only=True),
"forum_report_reasons": FORUM_REPORT_REASONS, "forum_report_reasons": FORUM_REPORT_REASONS,
"notifications_unread_count": notifications_unread_count, "notifications_unread_count": notifications_unread_count,
"lang": lang,
} }
def _humanize_time(dt): def _humanize_time(dt, lang=None):
if not dt: if not dt:
return "" return ""
active_lang = lang or session.get("lang", "zh")
if dt.tzinfo is None: if dt.tzinfo is None:
now = datetime.utcnow() now = datetime.utcnow()
else: else:
@@ -354,22 +493,26 @@ def _humanize_time(dt):
if seconds < 0: if seconds < 0:
return dt.strftime("%Y-%m-%d") return dt.strftime("%Y-%m-%d")
if seconds < 60: if seconds < 60:
return "刚刚" return "just now" if active_lang == "en" else "刚刚"
if seconds < 3600: 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: 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: 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") 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 = [] cards = []
for post, reply_count, latest_activity, author_name, like_count, bookmark_count in rows: for post, reply_count, latest_activity, author_name, like_count, bookmark_count in rows:
latest_activity = latest_activity or post.created_at latest_activity = latest_activity or post.created_at
username = author_name or "用户" username = author_name or _pick_lang("用户", "User", active_lang)
cards.append({ cards.append({
"post": post, "post": post,
"reply_count": int(reply_count or 0), "reply_count": int(reply_count or 0),
@@ -377,14 +520,14 @@ def _build_forum_post_cards(rows):
"like_count": int(like_count or 0), "like_count": int(like_count or 0),
"bookmark_count": int(bookmark_count or 0), "bookmark_count": int(bookmark_count or 0),
"latest_activity": latest_activity, "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_name": username,
"author_initial": (username[0] if username else "?").upper(), "author_initial": (username[0] if username else "?").upper(),
}) })
return cards 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 简洁。""" """构建论坛列表页链接,并尽量保持 URL 简洁。"""
params = {} params = {}
if (tab or "latest") != "latest": if (tab or "latest") != "latest":
@@ -395,6 +538,10 @@ def _build_forum_url(tab="latest", category=None, q=None, page=1):
params["q"] = q params["q"] = q
if page and int(page) > 1: if page and int(page) > 1:
params["page"] = int(page) params["page"] = int(page)
if per_page:
size = int(per_page)
if size != 20:
params["per_page"] = size
return url_for("forum_index", **params) 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(): 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 = ( category_counts = (
db.session.query(ForumPost.category, func.count(ForumPost.id)) db.session.query(ForumPost.category, func.count(ForumPost.id))
.group_by(ForumPost.category) .group_by(ForumPost.category)
@@ -487,13 +639,39 @@ def _forum_sidebar_data():
.limit(6) .limit(6)
.all() .all()
) )
return { data = {
"total_users": User.query.count(), "total_users": User.query.count(),
"total_posts": ForumPost.query.count(), "total_posts": ForumPost.query.count(),
"total_comments": ForumComment.query.count(), "total_comments": ForumComment.query.count(),
"category_counts": list(category_counts), "category_counts": list(category_counts),
"active_users": list(active_users), "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): def _currency_symbol(currency):
@@ -715,10 +893,7 @@ I18N = {
@app.route("/") @app.route("/")
def index(): def index():
lang = request.args.get("lang") or session.get("lang", "zh") lang = _get_lang()
if lang not in ("zh", "en"):
lang = "zh"
session["lang"] = lang
t = I18N[lang] t = I18N[lang]
plans = VPSPlan.query.order_by(VPSPlan.provider, VPSPlan.price_cny).all() plans = VPSPlan.query.order_by(VPSPlan.provider, VPSPlan.price_cny).all()
return render_template( return render_template(
@@ -740,6 +915,7 @@ def api_plans():
# ---------- 前台用户与论坛 ---------- # ---------- 前台用户与论坛 ----------
@app.route("/register", methods=["GET", "POST"]) @app.route("/register", methods=["GET", "POST"])
def user_register(): def user_register():
lang = _get_lang()
current = _get_current_user() current = _get_current_user()
if current: if current:
if _is_banned_user(current): if _is_banned_user(current):
@@ -753,13 +929,17 @@ def user_register():
confirm_password = request.form.get("confirm_password") or "" confirm_password = request.form.get("confirm_password") or ""
if not _is_valid_username(username): 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: elif len(password) < 6:
error = "密码至少 6 位" error = _pick_lang("密码至少 6 位", "Password must be at least 6 characters.", lang)
elif password != confirm_password: elif password != confirm_password:
error = "两次输入的密码不一致" error = _pick_lang("两次输入的密码不一致", "Passwords do not match.", lang)
elif User.query.filter(func.lower(User.username) == username.lower()).first(): elif User.query.filter(func.lower(User.username) == username.lower()).first():
error = "用户名已存在" error = _pick_lang("用户名已存在", "Username already exists.", lang)
else: else:
user = User(username=username) user = User(username=username)
user.set_password(password) user.set_password(password)
@@ -774,6 +954,7 @@ def user_register():
@app.route("/login", methods=["GET", "POST"]) @app.route("/login", methods=["GET", "POST"])
def user_login(): def user_login():
lang = _get_lang()
current = _get_current_user() current = _get_current_user()
if current: if current:
if _is_banned_user(current): if _is_banned_user(current):
@@ -786,7 +967,7 @@ def user_login():
password = request.form.get("password") or "" password = request.form.get("password") or ""
user = User.query.filter(func.lower(User.username) == username.lower()).first() user = User.query.filter(func.lower(User.username) == username.lower()).first()
if not user or not user.check_password(password): if not user or not user.check_password(password):
error = "用户名或密码错误" error = _pick_lang("用户名或密码错误", "Invalid username or password.", lang)
elif _is_banned_user(user): elif _is_banned_user(user):
error = _user_ban_message(user) error = _user_ban_message(user)
else: else:
@@ -811,6 +992,7 @@ def user_profile_redirect():
@app.route("/me", methods=["GET", "POST"]) @app.route("/me", methods=["GET", "POST"])
@user_login_required @user_login_required
def user_profile(): def user_profile():
lang = _get_lang()
user = _get_current_user() user = _get_current_user()
tab = (request.args.get("tab") or "posts").strip().lower() tab = (request.args.get("tab") or "posts").strip().lower()
if tab not in {"posts", "comments", "likes", "bookmarks", "settings"}: if tab not in {"posts", "comments", "likes", "bookmarks", "settings"}:
@@ -821,42 +1003,50 @@ def user_profile():
if action == "profile": if action == "profile":
username = (request.form.get("username") or "").strip() username = (request.form.get("username") or "").strip()
if username == user.username: 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): 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 = ( exists = (
User.query User.query
.filter(func.lower(User.username) == username.lower(), User.id != user.id) .filter(func.lower(User.username) == username.lower(), User.id != user.id)
.first() .first()
) )
if exists: 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 user.username = username
db.session.commit() 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": if action == "password":
current_password = request.form.get("current_password") or "" current_password = request.form.get("current_password") or ""
new_password = request.form.get("new_password") or "" new_password = request.form.get("new_password") or ""
confirm_password = request.form.get("confirm_password") or "" confirm_password = request.form.get("confirm_password") or ""
if not user.check_password(current_password): 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: 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: 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) user.set_password(new_password)
db.session.commit() 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 = ( my_post_rows = (
_query_forum_post_rows(active_tab="latest", author_id=user.id) _query_forum_post_rows(active_tab="latest", author_id=user.id)
.limit(60) .limit(60)
.all() .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 = ( my_comment_rows = (
db.session.query( db.session.query(
ForumComment, ForumComment,
@@ -952,11 +1142,16 @@ def user_profile():
@app.route("/notifications") @app.route("/notifications")
@user_login_required @user_login_required
def user_notifications(): def user_notifications():
lang = _get_lang()
user = _get_current_user() user = _get_current_user()
status = (request.args.get("status") or "all").strip().lower() status = (request.args.get("status") or "all").strip().lower()
if status not in {"all", "unread", "read"}: if status not in {"all", "unread", "read"}:
status = "all" 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": if status == "unread":
q = q.filter_by(is_read=False) q = q.filter_by(is_read=False)
elif status == "read": elif status == "read":
@@ -966,13 +1161,24 @@ def user_notifications():
for n in rows: for n in rows:
items.append({ items.append({
"notification": n, "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 "", "actor_name": n.actor_rel.username if n.actor_rel else "",
"target_url": _notification_target_url(n), "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() status_rows = (
read_count = ForumNotification.query.filter_by(user_id=user.id, is_read=True).count() 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( return render_template(
"forum/notifications.html", "forum/notifications.html",
active_status=status, active_status=status,
@@ -988,10 +1194,11 @@ def user_notifications():
@app.route("/notification/<int:notification_id>/go") @app.route("/notification/<int:notification_id>/go")
@user_login_required @user_login_required
def user_notification_go(notification_id): def user_notification_go(notification_id):
lang = _get_lang()
user = _get_current_user() user = _get_current_user()
n = ForumNotification.query.get_or_404(notification_id) n = ForumNotification.query.get_or_404(notification_id)
if n.user_id != user.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: if not n.is_read:
n.is_read = True n.is_read = True
db.session.commit() db.session.commit()
@@ -1001,32 +1208,36 @@ def user_notification_go(notification_id):
@app.route("/notification/<int:notification_id>/read", methods=["POST"]) @app.route("/notification/<int:notification_id>/read", methods=["POST"])
@user_login_required @user_login_required
def user_notification_read(notification_id): def user_notification_read(notification_id):
lang = _get_lang()
user = _get_current_user() user = _get_current_user()
n = ForumNotification.query.get_or_404(notification_id) n = ForumNotification.query.get_or_404(notification_id)
if n.user_id != user.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: if not n.is_read:
n.is_read = True n.is_read = True
db.session.commit() db.session.commit()
next_url = (request.form.get("next") or "").strip() next_url = (request.form.get("next") or "").strip()
if next_url.startswith("/") and not next_url.startswith("//"): if next_url.startswith("/") and not next_url.startswith("//"):
return redirect(next_url) 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"]) @app.route("/notifications/read-all", methods=["POST"])
@user_login_required @user_login_required
def user_notifications_read_all(): def user_notifications_read_all():
lang = _get_lang()
user = _get_current_user() user = _get_current_user()
unread = ForumNotification.query.filter_by(user_id=user.id, is_read=False) unread = ForumNotification.query.filter_by(user_id=user.id, is_read=False)
updated = unread.update({"is_read": True}, synchronize_session=False) updated = unread.update({"is_read": True}, synchronize_session=False)
db.session.commit() 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)) return redirect(url_for("user_notifications", msg=msg))
@app.route("/forum") @app.route("/forum")
def forum_index(): def forum_index():
lang = _get_lang()
per_page_options = [10, 20, 30, 50]
active_tab = (request.args.get("tab") or "latest").strip().lower() active_tab = (request.args.get("tab") or "latest").strip().lower()
if active_tab not in {"latest", "new", "hot"}: if active_tab not in {"latest", "new", "hot"}:
active_tab = "latest" active_tab = "latest"
@@ -1039,19 +1250,24 @@ def forum_index():
page = request.args.get("page", type=int) or 1 page = request.args.get("page", type=int) or 1
if page < 1: if page < 1:
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( rows_query = _query_forum_post_rows(
active_tab=active_tab, active_tab=active_tab,
selected_category=selected_category, selected_category=selected_category,
search_query=search_query or None, 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) total_pages = max((total_posts + per_page - 1) // per_page, 1)
if page > total_pages: if page > total_pages:
page = total_pages page = total_pages
rows = rows_query.offset((page - 1) * per_page).limit(per_page).all() 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() sidebar = _forum_sidebar_data()
category_count_map = {name: int(count or 0) for name, count in (sidebar.get("category_counts") or [])} 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) category_names.insert(0, selected_category)
tab_defs = [ tab_defs = [
("latest", "最新"), ("latest", _pick_lang("最新", "Latest", lang)),
("new", "新帖"), ("new", _pick_lang("新帖", "New", lang)),
("hot", "热门"), ("hot", _pick_lang("热门", "Top", lang)),
] ]
tab_links = [ tab_links = [
{ {
@@ -1076,6 +1292,7 @@ def forum_index():
category=selected_category, category=selected_category,
q=search_query or None, q=search_query or None,
page=1, page=1,
per_page=per_page,
), ),
"active": active_tab == key, "active": active_tab == key,
} }
@@ -1083,9 +1300,15 @@ def forum_index():
] ]
category_links = [ category_links = [
{ {
"name": "全部", "name": _pick_lang("全部", "All", lang),
"count": None, "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, "active": selected_category is None,
} }
] ]
@@ -1093,7 +1316,13 @@ def forum_index():
category_links.append({ category_links.append({
"name": name, "name": name,
"count": category_count_map.get(name, 0), "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, "active": selected_category == name,
}) })
@@ -1102,6 +1331,7 @@ def forum_index():
category=selected_category or (category_names[0] if category_names else None), category=selected_category or (category_names[0] if category_names else None),
q=search_query or None, q=search_query or None,
page=1, page=1,
per_page=per_page,
) )
window_start = max(1, page - 2) window_start = max(1, page - 2)
@@ -1114,6 +1344,7 @@ def forum_index():
category=selected_category, category=selected_category,
q=search_query or None, q=search_query or None,
page=num, page=num,
per_page=per_page,
), ),
"active": num == page, "active": num == page,
} }
@@ -1122,13 +1353,13 @@ def forum_index():
has_filters = bool(selected_category or search_query or active_tab != "latest") has_filters = bool(selected_category or search_query or active_tab != "latest")
if search_query and selected_category: if search_query and selected_category:
empty_hint = "当前分类下没有匹配关键词的帖子。" empty_hint = _pick_lang("当前分类下没有匹配关键词的帖子。", "No posts match your keywords in this category.", lang)
elif search_query: elif search_query:
empty_hint = "没有匹配关键词的帖子。" empty_hint = _pick_lang("没有匹配关键词的帖子。", "No posts match your keywords.", lang)
elif selected_category: elif selected_category:
empty_hint = "该分类暂时没有帖子。" empty_hint = _pick_lang("该分类暂时没有帖子。", "No posts in this category yet.", lang)
else: 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_start = ((page - 1) * per_page + 1) if total_posts else 0
result_end = min(page * per_page, total_posts) 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, category=selected_category,
q=search_query or None, q=search_query or None,
page=page - 1, page=page - 1,
per_page=per_page,
), ),
next_page_url=_build_forum_url( next_page_url=_build_forum_url(
tab=active_tab, tab=active_tab,
category=selected_category, category=selected_category,
q=search_query or None, q=search_query or None,
page=page + 1, page=page + 1,
per_page=per_page,
), ),
clear_search_url=_build_forum_url( clear_search_url=_build_forum_url(
tab=active_tab, tab=active_tab,
category=selected_category, category=selected_category,
q=None, q=None,
page=1, 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, has_filters=has_filters,
empty_hint=empty_hint, empty_hint=empty_hint,
result_start=result_start, result_start=result_start,
result_end=result_end, result_end=result_end,
per_page=per_page,
per_page_options=per_page_options,
message=request.args.get("msg") or "", message=request.args.get("msg") or "",
error=request.args.get("error") or "", error=request.args.get("error") or "",
) )
@@ -1180,6 +1416,7 @@ def forum_index():
@app.route("/forum/post/new", methods=["GET", "POST"]) @app.route("/forum/post/new", methods=["GET", "POST"])
@user_login_required @user_login_required
def forum_post_new(): def forum_post_new():
lang = _get_lang()
user = _get_current_user() user = _get_current_user()
blocked_resp = _ensure_forum_interaction_user(user) blocked_resp = _ensure_forum_interaction_user(user)
if blocked_resp: if blocked_resp:
@@ -1196,11 +1433,11 @@ def forum_post_new():
if category not in available_categories: if category not in available_categories:
category = available_categories[0] if available_categories else "综合讨论" category = available_categories[0] if available_categories else "综合讨论"
if len(title) < 5: if len(title) < 5:
error = "标题至少 5 个字符" error = _pick_lang("标题至少 5 个字符", "Title must be at least 5 characters.", lang)
elif len(title) > 160: elif len(title) > 160:
error = "标题不能超过 160 个字符" error = _pick_lang("标题不能超过 160 个字符", "Title must be 160 characters or fewer.", lang)
elif len(content) < 10: elif len(content) < 10:
error = "内容至少 10 个字符" error = _pick_lang("内容至少 10 个字符", "Content must be at least 10 characters.", lang)
else: else:
post = ForumPost( post = ForumPost(
user_id=user.id, user_id=user.id,
@@ -1218,8 +1455,8 @@ def forum_post_new():
content_val=content, content_val=content,
category_val=category, category_val=category,
categories=available_categories, categories=available_categories,
page_title="创建新主题", page_title=_pick_lang("创建新主题", "Create Topic", lang),
submit_text="发布主题", submit_text=_pick_lang("发布主题", "Publish", lang),
action_url=url_for("forum_post_new"), action_url=url_for("forum_post_new"),
cancel_url=url_for("forum_index"), cancel_url=url_for("forum_index"),
form_mode="create", form_mode="create",
@@ -1229,6 +1466,7 @@ def forum_post_new():
@app.route("/forum/post/<int:post_id>/edit", methods=["GET", "POST"]) @app.route("/forum/post/<int:post_id>/edit", methods=["GET", "POST"])
@user_login_required @user_login_required
def forum_post_edit(post_id): def forum_post_edit(post_id):
lang = _get_lang()
post = ForumPost.query.get_or_404(post_id) post = ForumPost.query.get_or_404(post_id)
user = _get_current_user() user = _get_current_user()
blocked_resp = _ensure_forum_interaction_user(user, post_id=post.id) 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: if category not in available_categories:
category = available_categories[0] if available_categories else "综合讨论" category = available_categories[0] if available_categories else "综合讨论"
if len(title) < 5: if len(title) < 5:
error = "标题至少 5 个字符" error = _pick_lang("标题至少 5 个字符", "Title must be at least 5 characters.", lang)
elif len(title) > 160: elif len(title) > 160:
error = "标题不能超过 160 个字符" error = _pick_lang("标题不能超过 160 个字符", "Title must be 160 characters or fewer.", lang)
elif len(content) < 10: elif len(content) < 10:
error = "内容至少 10 个字符" error = _pick_lang("内容至少 10 个字符", "Content must be at least 10 characters.", lang)
else: else:
post.title = title post.title = title
post.content = content post.content = content
@@ -1270,8 +1508,8 @@ def forum_post_edit(post_id):
content_val=content, content_val=content,
category_val=category, category_val=category,
categories=available_categories, categories=available_categories,
page_title="编辑主题", page_title=_pick_lang("编辑主题", "Edit Topic", lang),
submit_text="保存修改", submit_text=_pick_lang("保存修改", "Save Changes", lang),
action_url=url_for("forum_post_edit", post_id=post.id), action_url=url_for("forum_post_edit", post_id=post.id),
cancel_url=url_for("forum_post_detail", post_id=post.id), cancel_url=url_for("forum_post_detail", post_id=post.id),
form_mode="edit", form_mode="edit",
@@ -1305,6 +1543,7 @@ def forum_post_detail(post_id):
db.session.commit() db.session.commit()
comments = ( comments = (
ForumComment.query ForumComment.query
.options(joinedload(ForumComment.author_rel))
.filter_by(post_id=post.id) .filter_by(post_id=post.id)
.order_by(ForumComment.created_at.asc(), ForumComment.id.asc()) .order_by(ForumComment.created_at.asc(), ForumComment.id.asc())
.all() .all()
@@ -1591,6 +1830,11 @@ Sitemap: {url}/sitemap.xml
return resp 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"]) @app.route("/admin/login", methods=["GET", "POST"])
def admin_login(): def admin_login():

View File

@@ -3,3 +3,5 @@ flask-sqlalchemy>=3.1.0
PyMySQL>=1.1.0 PyMySQL>=1.1.0
cryptography>=41.0.0 cryptography>=41.0.0
openpyxl>=3.1.0 openpyxl>=3.1.0
markdown>=3.6
bleach>=6.1.0

656
seed_forum_demo.py Normal file
View File

@@ -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": """这篇给一个可直接落地的域名绑定流程,适合新站上线:
步骤 1DNS 解析
- A 记录指向服务器公网 IP
- 等待生效(通常 1-10 分钟,部分服务商更久)。
步骤 2Nginx 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": "宝塔部署 FlaskGunicorn + 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/443Web 流量入口(建议统一走 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": """我在宝塔上部署了 FlaskNginx 状态是绿色“运行中”,但是访问域名一直 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. 对比不同 DNS8.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 Pageserver_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": """我按教程装了 OpenClawsystemd 状态是 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()

View File

@@ -41,17 +41,27 @@
} }
.forum-logo { .forum-logo {
font-family: var(--font-mono); display: inline-flex;
font-size: 1.5rem; align-items: center;
font-weight: 700; gap: 0.45rem;
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;
text-decoration: none; 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 { .forum-primary-nav {
@@ -306,6 +316,10 @@
font-family: var(--font-mono); font-family: var(--font-mono);
} }
.topic-col-mini {
text-align: center;
}
.topic-list { .topic-list {
list-style: none; list-style: none;
margin: 0; margin: 0;
@@ -313,13 +327,45 @@
} }
.topic-result { .topic-result {
padding: 0.5rem 0.92rem;
color: var(--text-muted); color: var(--text-muted);
font-size: 0.78rem; 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); 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 { .topic-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 90px 90px 110px; grid-template-columns: minmax(0, 1fr) 90px 90px 110px;
@@ -327,6 +373,7 @@
min-height: 74px; min-height: 74px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
transition: var(--transition); transition: var(--transition);
padding: 0 0.92rem;
} }
.topic-row:last-child { .topic-row:last-child {
@@ -342,7 +389,7 @@
align-items: center; align-items: center;
gap: 0.68rem; gap: 0.68rem;
min-width: 0; min-width: 0;
padding: 0.68rem 0.95rem; padding: 0.68rem 0;
} }
.topic-avatar { .topic-avatar {
@@ -610,7 +657,7 @@
} }
.topic-post-content { .topic-post-content {
white-space: pre-wrap; white-space: normal;
color: var(--text); color: var(--text);
line-height: 1.66; line-height: 1.66;
font-size: 0.95rem; font-size: 0.95rem;
@@ -629,6 +676,13 @@
font-size: 0.82rem; font-size: 0.82rem;
} }
.form-help {
display: block;
margin-top: 0.35rem;
color: var(--text-muted);
font-size: 0.76rem;
}
.comment-form textarea, .comment-form textarea,
.post-form input, .post-form input,
.post-form textarea, .post-form textarea,
@@ -715,13 +769,74 @@
} }
.comment-content { .comment-content {
white-space: pre-wrap; white-space: normal;
margin-top: 0.32rem; margin-top: 0.32rem;
color: var(--text); color: var(--text);
line-height: 1.58; line-height: 1.58;
font-size: 0.9rem; 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 { .comment-actions {
margin-top: 0.44rem; margin-top: 0.44rem;
display: flex; display: flex;
@@ -1031,8 +1146,13 @@
justify-content: flex-start; justify-content: flex-start;
} }
.forum-logo { .forum-logo img {
font-size: 1.5rem; width: 30px;
height: 30px;
}
.forum-logo span {
font-size: 0.96rem;
} }
.forum-shell { .forum-shell {
@@ -1057,6 +1177,11 @@
width: 100%; width: 100%;
} }
.topic-footer {
flex-direction: column;
align-items: flex-start;
}
.topic-head { .topic-head {
grid-template-columns: minmax(0, 1fr) 64px 64px; grid-template-columns: minmax(0, 1fr) 64px 64px;
} }

View File

@@ -73,6 +73,18 @@ body {
gap: 0.75rem; 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 { .logo {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 1.5rem; font-size: 1.5rem;
@@ -117,24 +129,34 @@ body {
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
margin-right: 0.5rem; 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 { .lang-switch a {
color: var(--accent); color: var(--text-muted);
text-decoration: none; text-decoration: none;
padding: 0.2rem 0.4rem; padding: 0.3rem 0.6rem;
border-radius: 4px; border-radius: 4px;
transition: var(--transition);
font-weight: 500;
} }
.lang-switch a:hover { .lang-switch a:hover {
background: var(--accent-glow); color: var(--accent);
background: var(--bg-card);
} }
.lang-switch a.active { .lang-switch a.active {
font-weight: 600; font-weight: 600;
color: var(--text); color: var(--text);
background: var(--bg-card);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
} }
.lang-sep { .lang-sep {
color: var(--text-muted); color: var(--border);
user-select: none; user-select: none;
font-weight: 300;
} }
.nav-link-with-badge { .nav-link-with-badge {
@@ -181,13 +203,13 @@ body {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); box-shadow: var(--shadow);
transition: var(--transition); transition: var(--transition);
} }
.filters:hover { .filters:hover {
border-color: var(--border-hover); border-color: var(--border-hover);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); box-shadow: var(--shadow-lg);
} }
.filter-group { .filter-group {
@@ -378,6 +400,21 @@ body {
color: var(--text-muted); 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 { .footer {
margin-top: auto; margin-top: auto;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
@@ -462,12 +499,13 @@ body {
@media (max-width: 768px) { @media (max-width: 768px) {
.header { .header {
padding: 1.5rem 1rem; padding: 1rem;
} }
.header-inner { .header-inner {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 0.75rem;
} }
.header-brand { .header-brand {
@@ -478,10 +516,29 @@ body {
.header-nav { .header-nav {
width: 100%; width: 100%;
justify-content: flex-start; 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 { .logo {
font-size: 1.5rem; font-size: 1.25rem;
}
.site-logo {
width: clamp(148px, 44vw, 210px);
}
.tagline {
font-size: 0.8rem;
} }
.filters { .filters {
@@ -490,6 +547,14 @@ body {
padding: 1rem; padding: 1rem;
} }
.filter-group {
min-width: 100%;
}
.filter-group select {
width: 100%;
}
.btn-reset { .btn-reset {
margin-left: 0; margin-left: 0;
width: 100%; width: 100%;
@@ -543,7 +608,7 @@ html {
} }
.filter-group-search input { .filter-group-search input {
font-family: var(--font-mono); font-family: var(--font-sans);
font-size: 0.9rem; font-size: 0.9rem;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
width: 100%; width: 100%;
@@ -554,6 +619,10 @@ html {
transition: var(--transition); transition: var(--transition);
} }
.filter-group-search input:hover {
border-color: var(--accent);
}
.filter-group-search input:focus { .filter-group-search input:focus {
outline: none; outline: none;
border-color: var(--accent); border-color: var(--accent);
@@ -562,6 +631,7 @@ html {
.filter-group-search input::placeholder { .filter-group-search input::placeholder {
color: var(--text-muted); color: var(--text-muted);
opacity: 0.7;
} }
/* 可排序表头 */ /* 可排序表头 */

View File

@@ -0,0 +1,24 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="markTitle markDesc">
<title id="markTitle">VPS Price Mark</title>
<desc id="markDesc">A square icon with stacked server bars and upward trend arrow.</desc>
<defs>
<linearGradient id="markBg" x1="36" y1="36" x2="476" y2="476" gradientUnits="userSpaceOnUse">
<stop stop-color="#0F172A"/>
<stop offset="1" stop-color="#0369A1"/>
</linearGradient>
<linearGradient id="markTrend" x1="146" y1="350" x2="372" y2="136" gradientUnits="userSpaceOnUse">
<stop stop-color="#0284C7"/>
<stop offset="1" stop-color="#059669"/>
</linearGradient>
</defs>
<rect x="36" y="36" width="440" height="440" rx="102" fill="url(#markBg)"/>
<rect x="122" y="132" width="268" height="60" rx="24" fill="#E2E8F0" fill-opacity="0.95"/>
<rect x="122" y="218" width="268" height="60" rx="24" fill="#E2E8F0" fill-opacity="0.85"/>
<rect x="122" y="304" width="268" height="60" rx="24" fill="#E2E8F0" fill-opacity="0.75"/>
<circle cx="154" cy="162" r="9" fill="#0369A1"/>
<circle cx="154" cy="248" r="9" fill="#0369A1"/>
<circle cx="154" cy="334" r="9" fill="#0369A1"/>
<path d="M146 350L212 296L260 312L364 214" stroke="url(#markTrend)" stroke-width="21" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M328 214H378V264" stroke="#059669" stroke-width="21" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

31
static/img/site-logo.svg Normal file
View File

@@ -0,0 +1,31 @@
<svg width="980" height="260" viewBox="0 0 980 260" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="logoTitle logoDesc">
<title id="logoTitle">VPS Price Logo</title>
<desc id="logoDesc">A modern cloud server price comparison logo with a stacked server icon and upward trend arrow.</desc>
<defs>
<linearGradient id="iconBg" x1="20" y1="20" x2="220" y2="220" gradientUnits="userSpaceOnUse">
<stop stop-color="#0F172A"/>
<stop offset="1" stop-color="#0369A1"/>
</linearGradient>
<linearGradient id="trend" x1="74" y1="168" x2="182" y2="66" gradientUnits="userSpaceOnUse">
<stop stop-color="#0284C7"/>
<stop offset="1" stop-color="#059669"/>
</linearGradient>
</defs>
<g transform="translate(20,20)">
<rect x="0" y="0" width="220" height="220" rx="52" fill="url(#iconBg)"/>
<rect x="44" y="48" width="132" height="30" rx="12" fill="#E2E8F0" fill-opacity="0.95"/>
<rect x="44" y="92" width="132" height="30" rx="12" fill="#E2E8F0" fill-opacity="0.85"/>
<rect x="44" y="136" width="132" height="30" rx="12" fill="#E2E8F0" fill-opacity="0.75"/>
<circle cx="60" cy="63" r="4.5" fill="#0369A1"/>
<circle cx="60" cy="107" r="4.5" fill="#0369A1"/>
<circle cx="60" cy="151" r="4.5" fill="#0369A1"/>
<path d="M72 160L105 133L130 141L175 92" stroke="url(#trend)" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M159 92H182V115" stroke="#059669" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<g transform="translate(278,66)">
<text x="0" y="62" fill="#0F172A" font-family="Poppins, Open Sans, Noto Sans SC, sans-serif" font-size="70" font-weight="700" letter-spacing="0.5">VPS Price</text>
<text x="3" y="110" fill="#0369A1" font-family="Open Sans, Poppins, Noto Sans SC, sans-serif" font-size="32" font-weight="600" letter-spacing="2">FORUM &amp; COMPARE</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -293,6 +293,8 @@
var currentPrice = getPriceValue(plan, filters.currency); var currentPrice = getPriceValue(plan, filters.currency);
var displayPrice = currentPrice ? currentPrice.symbol + currentPrice.value.toFixed(2) : '—'; var displayPrice = currentPrice ? currentPrice.symbol + currentPrice.value.toFixed(2) : '—';
var btnText = (window.I18N_JS && window.I18N_JS.btn_visit) || '访问';
tr.innerHTML = tr.innerHTML =
'<td>' + escapeHtml(plan.provider) + '</td>' + '<td>' + escapeHtml(plan.provider) + '</td>' +
'<td>' + escapeHtml(plan.countries) + '</td>' + '<td>' + escapeHtml(plan.countries) + '</td>' +
@@ -304,7 +306,7 @@
'<td>' + plan.traffic + '</td>' + '<td>' + plan.traffic + '</td>' +
'<td class="col-price">' + displayPrice + '</td>' + '<td class="col-price">' + displayPrice + '</td>' +
'<td class="col-link">' + '<td class="col-link">' +
'<a href="' + escapeHtml(plan.official_url) + '" target="_blank" rel="noopener" class="btn-link">访问</a>' + '<a href="' + escapeHtml(plan.official_url) + '" target="_blank" rel="noopener" class="btn-link">' + btnText + '</a>' +
'</td>'; '</td>';
return tr; return tr;

View File

@@ -1,9 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{ 'zh-CN' if lang == 'zh' else 'en' }}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>用户登录 - 云价眼</title> <title>{{ l('用户登录', 'Login') }} - {{ l('云价眼', 'VPS Price') }}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='img/site-logo-mark.svg') }}">
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/forum.css"> <link rel="stylesheet" href="/static/css/forum.css">
</head> </head>
@@ -11,33 +12,43 @@
<header class="forum-header"> <header class="forum-header">
<div class="forum-header-inner"> <div class="forum-header-inner">
<div class="forum-header-left"> <div class="forum-header-left">
<a href="{{ url_for('forum_index') }}" class="forum-logo">VPS 论坛</a> <a href="{{ url_for('forum_index') }}" class="forum-logo">
<img src="{{ url_for('static', filename='img/site-logo-mark.svg') }}" alt="{{ l('云价眼 Logo', 'VPS Price Logo') }}">
<span>{{ l('云价眼论坛', 'VPS Price Forum') }}</span>
</a>
<nav class="forum-primary-nav"> <nav class="forum-primary-nav">
<a href="{{ url_for('forum_index') }}" class="active">最新</a> <a href="{{ url_for('forum_index') }}" class="active">{{ l('最新', 'Latest') }}</a>
<a href="{{ url_for('index') }}">价格表</a> <a href="{{ url_for('index') }}">{{ l('价格表', 'Pricing') }}</a>
</nav> </nav>
</div> </div>
<div class="forum-header-right">
<span class="lang-switch">
<a href="{{ lang_url('zh') }}" class="{{ 'active' if lang == 'zh' else '' }}" title="切换到中文">中文</a>
<span class="lang-sep">|</span>
<a href="{{ lang_url('en') }}" class="{{ 'active' if lang == 'en' else '' }}" title="Switch to English">English</a>
</span>
</div>
</div> </div>
</header> </header>
<main class="forum-shell"> <main class="forum-shell">
<section class="topic-post-card auth-panel"> <section class="topic-post-card auth-panel">
<h1>登录账号</h1> <h1>{{ l('登录账号', 'Sign In') }}</h1>
{% if error %} {% if error %}
<p class="form-error">{{ error }}</p> <p class="form-error">{{ error }}</p>
{% endif %} {% endif %}
<form method="post" action="{{ url_for('user_login') }}" class="post-form"> <form method="post" action="{{ url_for('user_login') }}" class="post-form">
<input type="hidden" name="next" value="{{ request.values.get('next', '') }}"> <input type="hidden" name="next" value="{{ request.values.get('next', '') }}">
<div class="form-group"> <div class="form-group">
<label for="username">用户名</label> <label for="username">{{ l('用户名', 'Username') }}</label>
<input id="username" name="username" type="text" required autofocus> <input id="username" name="username" type="text" required autofocus>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">密码</label> <label for="password">{{ l('密码', 'Password') }}</label>
<input id="password" name="password" type="password" required> <input id="password" name="password" type="password" required>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="forum-btn-primary">登录</button> <button type="submit" class="forum-btn-primary">{{ l('登录', 'Login') }}</button>
<a href="{{ url_for('user_register', next=request.values.get('next', '')) }}" class="forum-btn-muted">去注册</a> <a href="{{ url_for('user_register', next=request.values.get('next', '')) }}" class="forum-btn-muted">{{ l('去注册', 'Create Account') }}</a>
</div> </div>
</form> </form>
</section> </section>

View File

@@ -1,9 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{ 'zh-CN' if lang == 'zh' else 'en' }}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>用户注册 - 云价眼</title> <title>{{ l('用户注册', 'Register') }} - {{ l('云价眼', 'VPS Price') }}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='img/site-logo-mark.svg') }}">
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/forum.css"> <link rel="stylesheet" href="/static/css/forum.css">
</head> </head>
@@ -11,37 +12,47 @@
<header class="forum-header"> <header class="forum-header">
<div class="forum-header-inner"> <div class="forum-header-inner">
<div class="forum-header-left"> <div class="forum-header-left">
<a href="{{ url_for('forum_index') }}" class="forum-logo">VPS 论坛</a> <a href="{{ url_for('forum_index') }}" class="forum-logo">
<img src="{{ url_for('static', filename='img/site-logo-mark.svg') }}" alt="{{ l('云价眼 Logo', 'VPS Price Logo') }}">
<span>{{ l('云价眼论坛', 'VPS Price Forum') }}</span>
</a>
<nav class="forum-primary-nav"> <nav class="forum-primary-nav">
<a href="{{ url_for('forum_index') }}" class="active">最新</a> <a href="{{ url_for('forum_index') }}" class="active">{{ l('最新', 'Latest') }}</a>
<a href="{{ url_for('index') }}">价格表</a> <a href="{{ url_for('index') }}">{{ l('价格表', 'Pricing') }}</a>
</nav> </nav>
</div> </div>
<div class="forum-header-right">
<span class="lang-switch">
<a href="{{ lang_url('zh') }}" class="{{ 'active' if lang == 'zh' else '' }}" title="切换到中文">中文</a>
<span class="lang-sep">|</span>
<a href="{{ lang_url('en') }}" class="{{ 'active' if lang == 'en' else '' }}" title="Switch to English">English</a>
</span>
</div>
</div> </div>
</header> </header>
<main class="forum-shell"> <main class="forum-shell">
<section class="topic-post-card auth-panel"> <section class="topic-post-card auth-panel">
<h1>注册账号</h1> <h1>{{ l('注册账号', 'Create Account') }}</h1>
{% if error %} {% if error %}
<p class="form-error">{{ error }}</p> <p class="form-error">{{ error }}</p>
{% endif %} {% endif %}
<form method="post" action="{{ url_for('user_register') }}" class="post-form"> <form method="post" action="{{ url_for('user_register') }}" class="post-form">
<input type="hidden" name="next" value="{{ request.values.get('next', '') }}"> <input type="hidden" name="next" value="{{ request.values.get('next', '') }}">
<div class="form-group"> <div class="form-group">
<label for="username">用户名</label> <label for="username">{{ l('用户名', 'Username') }}</label>
<input id="username" name="username" type="text" required minlength="3" maxlength="20" placeholder="3-20 位,字母/数字/下划线"> <input id="username" name="username" type="text" required minlength="3" maxlength="20" placeholder="{{ l('3-20 位,字母/数字/下划线', '3-20 chars, letters/numbers/_') }}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">密码</label> <label for="password">{{ l('密码', 'Password') }}</label>
<input id="password" name="password" type="password" required minlength="6"> <input id="password" name="password" type="password" required minlength="6">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="confirm_password">确认密码</label> <label for="confirm_password">{{ l('确认密码', 'Confirm Password') }}</label>
<input id="confirm_password" name="confirm_password" type="password" required minlength="6"> <input id="confirm_password" name="confirm_password" type="password" required minlength="6">
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="forum-btn-primary">注册并登录</button> <button type="submit" class="forum-btn-primary">{{ l('注册并登录', 'Register & Login') }}</button>
<a href="{{ url_for('user_login', next=request.values.get('next', '')) }}" class="forum-btn-muted">去登录</a> <a href="{{ url_for('user_login', next=request.values.get('next', '')) }}" class="forum-btn-muted">{{ l('去登录', 'Go to Login') }}</a>
</div> </div>
</form> </form>
</section> </section>

View File

@@ -1,9 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{ 'zh-CN' if lang == 'zh' else 'en' }}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>编辑评论 - 论坛</title> <title>{{ l('编辑评论', 'Edit Comment') }} - {{ l('论坛', 'Forum') }}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='img/site-logo-mark.svg') }}">
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/forum.css"> <link rel="stylesheet" href="/static/css/forum.css">
</head> </head>
@@ -11,35 +12,44 @@
<header class="forum-header"> <header class="forum-header">
<div class="forum-header-inner"> <div class="forum-header-inner">
<div class="forum-header-left"> <div class="forum-header-left">
<a href="{{ url_for('forum_index') }}" class="forum-logo">VPS 论坛</a> <a href="{{ url_for('forum_index') }}" class="forum-logo">
<img src="{{ url_for('static', filename='img/site-logo-mark.svg') }}" alt="{{ l('云价眼 Logo', 'VPS Price Logo') }}">
<span>{{ l('云价眼论坛', 'VPS Price Forum') }}</span>
</a>
<nav class="forum-primary-nav"> <nav class="forum-primary-nav">
<a href="{{ url_for('forum_index') }}">最新</a> <a href="{{ url_for('forum_index') }}">{{ l('最新', 'Latest') }}</a>
<a href="{{ cancel_url }}" class="active">返回帖子</a> <a href="{{ cancel_url }}" class="active">{{ l('返回帖子', 'Back to Topic') }}</a>
</nav> </nav>
</div> </div>
<div class="forum-header-right"> <div class="forum-header-right">
<span class="lang-switch">
<a href="{{ lang_url('zh') }}" class="{{ 'active' if lang == 'zh' else '' }}" title="切换到中文">中文</a>
<span class="lang-sep">|</span>
<a href="{{ lang_url('en') }}" class="{{ 'active' if lang == 'en' else '' }}" title="Switch to English">English</a>
</span>
<span class="forum-user-chip">{{ current_user.username }}</span> <span class="forum-user-chip">{{ current_user.username }}</span>
<a href="{{ url_for('user_profile') }}" class="forum-link">个人中心</a> <a href="{{ url_for('user_profile') }}" class="forum-link">{{ l('个人中心', 'Profile') }}</a>
<a href="{{ url_for('user_notifications') }}" class="forum-link nav-link-with-badge">通知{% if notifications_unread_count %}<span class="nav-badge">{{ notifications_unread_count }}</span>{% endif %}</a> <a href="{{ url_for('user_notifications') }}" class="forum-link nav-link-with-badge">{{ l('通知', 'Notifications') }}{% if notifications_unread_count %}<span class="nav-badge">{{ notifications_unread_count }}</span>{% endif %}</a>
<a href="{{ url_for('user_logout') }}" class="forum-link">退出</a> <a href="{{ url_for('user_logout') }}" class="forum-link">{{ l('退出', 'Logout') }}</a>
</div> </div>
</div> </div>
</header> </header>
<main class="forum-shell"> <main class="forum-shell">
<section class="topic-post-card auth-panel"> <section class="topic-post-card auth-panel">
<h1>编辑评论</h1> <h1>{{ l('编辑评论', 'Edit Comment') }}</h1>
{% if error %} {% if error %}
<p class="form-error">{{ error }}</p> <p class="form-error">{{ error }}</p>
{% endif %} {% endif %}
<form method="post" action="{{ action_url }}" class="post-form"> <form method="post" action="{{ action_url }}" class="post-form">
<div class="form-group"> <div class="form-group">
<label for="content">评论内容</label> <label for="content">{{ l('评论内容', 'Comment') }}</label>
<textarea id="content" name="content" required rows="6" minlength="2">{{ content_val or '' }}</textarea> <textarea id="content" name="content" required rows="6" minlength="2">{{ content_val or '' }}</textarea>
<small class="form-help">{{ l('支持 Markdown 代码块。', 'Markdown code blocks are supported.') }}</small>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="forum-btn-primary">保存修改</button> <button type="submit" class="forum-btn-primary">{{ l('保存修改', 'Save Changes') }}</button>
<a href="{{ cancel_url }}" class="forum-btn-muted">取消</a> <a href="{{ cancel_url }}" class="forum-btn-muted">{{ l('取消', 'Cancel') }}</a>
</div> </div>
</form> </form>
</section> </section>

View File

@@ -1,9 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{ 'zh-CN' if lang == 'zh' else 'en' }}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>论坛 - 云价眼</title> <title>{{ l('论坛', 'Forum') }} - {{ l('云价眼', 'VPS Price') }}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='img/site-logo-mark.svg') }}">
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/forum.css"> <link rel="stylesheet" href="/static/css/forum.css">
</head> </head>
@@ -13,24 +14,32 @@
<header class="forum-header"> <header class="forum-header">
<div class="forum-header-inner"> <div class="forum-header-inner">
<div class="forum-header-left"> <div class="forum-header-left">
<a href="{{ url_for('forum_index') }}" class="forum-logo">VPS 论坛</a> <a href="{{ url_for('forum_index') }}" class="forum-logo">
<img src="{{ url_for('static', filename='img/site-logo-mark.svg') }}" alt="{{ l('云价眼 Logo', 'VPS Price Logo') }}">
<span>{{ l('云价眼论坛', 'VPS Price Forum') }}</span>
</a>
<nav class="forum-primary-nav"> <nav class="forum-primary-nav">
{% for item in tab_links %} {% for item in tab_links %}
<a href="{{ item.url }}" class="{{ 'active' if item.active else '' }}">{{ item.label }}</a> <a href="{{ item.url }}" class="{{ 'active' if item.active else '' }}">{{ item.label }}</a>
{% endfor %} {% endfor %}
<a href="{{ category_nav_url }}" class="{{ 'active' if selected_category else '' }}">分类</a> <a href="{{ category_nav_url }}" class="{{ 'active' if selected_category else '' }}">{{ l('分类', 'Categories') }}</a>
<a href="{{ url_for('index') }}">价格表</a> <a href="{{ url_for('index') }}">{{ l('价格表', 'Pricing') }}</a>
</nav> </nav>
</div> </div>
<div class="forum-header-right"> <div class="forum-header-right">
<span class="lang-switch">
<a href="{{ lang_url('zh') }}" class="{{ 'active' if lang == 'zh' else '' }}" title="切换到中文">中文</a>
<span class="lang-sep">|</span>
<a href="{{ lang_url('en') }}" class="{{ 'active' if lang == 'en' else '' }}" title="Switch to English">English</a>
</span>
{% if current_user %} {% if current_user %}
<span class="forum-user-chip">{{ current_user.username }}{% if current_user.is_banned %}(封禁){% endif %}</span> <span class="forum-user-chip">{{ current_user.username }}{% if current_user.is_banned %}{{ l('(封禁)', ' (Banned)') }}{% endif %}</span>
<a href="{{ url_for('user_profile') }}" class="forum-link">个人中心</a> <a href="{{ url_for('user_profile') }}" class="forum-link">{{ l('个人中心', 'Profile') }}</a>
<a href="{{ url_for('user_notifications') }}" class="forum-link nav-link-with-badge">通知{% if notifications_unread_count %}<span class="nav-badge">{{ notifications_unread_count }}</span>{% endif %}</a> <a href="{{ url_for('user_notifications') }}" class="forum-link nav-link-with-badge">{{ l('通知', 'Notifications') }}{% if notifications_unread_count %}<span class="nav-badge">{{ notifications_unread_count }}</span>{% endif %}</a>
<a href="{{ url_for('user_logout') }}" class="forum-link">退出</a> <a href="{{ url_for('user_logout') }}" class="forum-link">{{ l('退出', 'Logout') }}</a>
{% else %} {% else %}
<a href="{{ url_for('user_login') }}" class="forum-link">登录</a> <a href="{{ url_for('user_login') }}" class="forum-link">{{ l('登录', 'Login') }}</a>
<a href="{{ url_for('user_register') }}" class="forum-link">注册</a> <a href="{{ url_for('user_register') }}" class="forum-link">{{ l('注册', 'Register') }}</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -42,15 +51,15 @@
{% for item in tab_links %} {% for item in tab_links %}
<a class="{{ 'active' if item.active else '' }}" href="{{ item.url }}">{{ item.label }}</a> <a class="{{ 'active' if item.active else '' }}" href="{{ item.url }}">{{ item.label }}</a>
{% endfor %} {% endfor %}
<a class="{{ 'active' if selected_category else '' }}" href="{{ category_nav_url }}">分类</a> <a class="{{ 'active' if selected_category else '' }}" href="{{ category_nav_url }}">{{ l('分类', 'Categories') }}</a>
</div> </div>
<div class="forum-actions"> <div class="forum-actions">
{% if current_user and not current_user.is_banned %} {% if current_user and not current_user.is_banned %}
<a href="{{ url_for('forum_post_new') }}" class="forum-btn-primary">+ 发布主题</a> <a href="{{ url_for('forum_post_new') }}" class="forum-btn-primary">+ {{ l('发布主题', 'New Topic') }}</a>
{% elif current_user and current_user.is_banned %} {% elif current_user and current_user.is_banned %}
<span class="forum-btn-muted">账号封禁中</span> <span class="forum-btn-muted">{{ l('账号封禁中', 'Account banned') }}</span>
{% else %} {% else %}
<a href="{{ url_for('user_login', next='/forum/post/new') }}" class="forum-btn-primary">登录后发帖</a> <a href="{{ url_for('user_login', next='/forum/post/new') }}" class="forum-btn-primary">{{ l('登录后发帖', 'Login to post') }}</a>
{% endif %} {% endif %}
</div> </div>
</section> </section>
@@ -64,16 +73,17 @@
<section class="forum-tools"> <section class="forum-tools">
<form method="get" action="{{ url_for('forum_index') }}" class="forum-search-form"> <form method="get" action="{{ url_for('forum_index') }}" class="forum-search-form">
<input type="hidden" name="tab" value="{{ active_tab }}"> <input type="hidden" name="tab" value="{{ active_tab }}">
<input type="hidden" name="per_page" value="{{ per_page }}">
{% if selected_category %} {% if selected_category %}
<input type="hidden" name="category" value="{{ selected_category }}"> <input type="hidden" name="category" value="{{ selected_category }}">
{% endif %} {% endif %}
<input type="text" name="q" value="{{ search_query or '' }}" placeholder="搜索标题、正文、作者" maxlength="80"> <input type="text" name="q" value="{{ search_query or '' }}" placeholder="{{ l('搜索标题、正文、作者', 'Search title, content, author') }}" maxlength="80">
<button type="submit" class="forum-btn-primary">搜索</button> <button type="submit" class="forum-btn-primary">{{ l('搜索', 'Search') }}</button>
{% if search_query %} {% if search_query %}
<a href="{{ clear_search_url }}" class="forum-btn-muted">清空搜索</a> <a href="{{ clear_search_url }}" class="forum-btn-muted">{{ l('清空搜索', 'Clear Search') }}</a>
{% endif %} {% endif %}
{% if has_filters %} {% if has_filters %}
<a href="{{ clear_all_url }}" class="forum-btn-muted">重置全部</a> <a href="{{ clear_all_url }}" class="forum-btn-muted">{{ l('重置全部', 'Reset All') }}</a>
{% endif %} {% endif %}
</form> </form>
<div class="category-bar"> <div class="category-bar">
@@ -91,14 +101,11 @@
<section class="forum-layout"> <section class="forum-layout">
<div class="topic-stream"> <div class="topic-stream">
<div class="topic-head"> <div class="topic-head">
<div class="topic-col-main">主题</div> <div class="topic-col-main">{{ l('主题', 'Topic') }}</div>
<div class="topic-col-mini">回复</div> <div class="topic-col-mini">{{ l('回复', 'Replies') }}</div>
<div class="topic-col-mini">浏览</div> <div class="topic-col-mini">{{ l('浏览', 'Views') }}</div>
<div class="topic-col-mini">活动</div> <div class="topic-col-mini">{{ l('活动', 'Activity') }}</div>
</div> </div>
{% if total_posts %}
<div class="topic-result">显示 {{ result_start }} - {{ result_end }} / 共 {{ total_posts }} 条</div>
{% endif %}
{% if cards %} {% if cards %}
<ul class="topic-list"> <ul class="topic-list">
@@ -109,17 +116,17 @@
<div class="topic-avatar">{{ item.author_initial }}</div> <div class="topic-avatar">{{ item.author_initial }}</div>
<div class="topic-content"> <div class="topic-content">
<a href="{{ url_for('forum_post_detail', post_id=post.id) }}" class="topic-title"> <a href="{{ url_for('forum_post_detail', post_id=post.id) }}" class="topic-title">
{% if post.is_pinned %}<span class="topic-flag flag-pinned">置顶</span>{% endif %} {% if post.is_pinned %}<span class="topic-flag flag-pinned">{{ l('置顶', 'Pinned') }}</span>{% endif %}
{% if post.is_featured %}<span class="topic-flag flag-featured">精华</span>{% endif %} {% if post.is_featured %}<span class="topic-flag flag-featured">{{ l('精华', 'Featured') }}</span>{% endif %}
{% if post.is_locked %}<span class="topic-flag flag-locked">锁帖</span>{% endif %} {% if post.is_locked %}<span class="topic-flag flag-locked">{{ l('锁帖', 'Locked') }}</span>{% endif %}
{{ post.title }} {{ post.title }}
</a> </a>
<div class="topic-meta"> <div class="topic-meta">
<span class="topic-category">{{ post.category or '综合讨论' }}</span> <span class="topic-category">{{ post.category or l('综合讨论', 'General') }}</span>
<span>{{ item.author_name }}</span> <span>{{ item.author_name }}</span>
<span>{{ post.created_at.strftime('%Y-%m-%d %H:%M') if post.created_at else '' }}</span> <span>{{ post.created_at.strftime('%Y-%m-%d %H:%M') if post.created_at else '' }}</span>
<span>点赞 {{ item.like_count }}</span> <span>{{ l('点赞', 'Likes') }} {{ item.like_count }}</span>
<span>收藏 {{ item.bookmark_count }}</span> <span>{{ l('收藏', 'Bookmarks') }} {{ item.bookmark_count }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -130,38 +137,66 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% if total_pages > 1 %} {% if total_pages > 1 %}
<nav class="forum-pagination" aria-label="帖子分页"> <nav class="forum-pagination" aria-label="{{ l('帖子分页', 'Post pagination') }}">
{% if has_prev %} {% if has_prev %}
<a href="{{ prev_page_url }}" class="page-link">上一页</a> <a href="{{ prev_page_url }}" class="page-link">{{ l('上一页', 'Prev') }}</a>
{% else %} {% else %}
<span class="page-link disabled">上一页</span> <span class="page-link disabled">{{ l('上一页', 'Prev') }}</span>
{% endif %} {% endif %}
{% for item in page_links %} {% for item in page_links %}
<a href="{{ item.url }}" class="page-link {{ 'active' if item.active else '' }}">{{ item.num }}</a> <a href="{{ item.url }}" class="page-link {{ 'active' if item.active else '' }}">{{ item.num }}</a>
{% endfor %} {% endfor %}
{% if has_next %} {% if has_next %}
<a href="{{ next_page_url }}" class="page-link">下一页</a> <a href="{{ next_page_url }}" class="page-link">{{ l('下一页', 'Next') }}</a>
{% else %} {% else %}
<span class="page-link disabled">下一页</span> <span class="page-link disabled">{{ l('下一页', 'Next') }}</span>
{% endif %} {% endif %}
</nav> </nav>
{% endif %} {% endif %}
{% else %} {% else %}
<div class="topic-empty">{{ empty_hint }}</div> <div class="topic-empty">{{ empty_hint }}</div>
{% endif %} {% endif %}
{% if total_posts %}
<div class="topic-footer">
<div class="topic-result">
{% if lang == 'en' %}
Page {{ current_page }} / {{ total_pages }} · Showing {{ result_start }} - {{ result_end }} of {{ total_posts }}
{% else %}
第 {{ current_page }} / {{ total_pages }} 页 · 显示 {{ result_start }} - {{ result_end }} / 共 {{ total_posts }} 条
{% endif %}
</div>
<form method="get" action="{{ url_for('forum_index') }}" class="page-size-form">
<input type="hidden" name="tab" value="{{ active_tab }}">
{% if selected_category %}
<input type="hidden" name="category" value="{{ selected_category }}">
{% endif %}
{% if search_query %}
<input type="hidden" name="q" value="{{ search_query }}">
{% endif %}
<label for="per-page-select">{{ l('每页', 'Per page') }}</label>
<select id="per-page-select" name="per_page" onchange="this.form.submit()">
{% for size in per_page_options %}
<option value="{{ size }}" {{ 'selected' if size == per_page else '' }}>{{ size }}</option>
{% endfor %}
</select>
<span>{{ l('条', 'items') }}</span>
<noscript><button type="submit" class="forum-btn-muted">{{ l('应用', 'Apply') }}</button></noscript>
</form>
</div>
{% endif %}
</div> </div>
<aside class="forum-sidebar"> <aside class="forum-sidebar">
<div class="side-card"> <div class="side-card">
<h3>社区统计</h3> <h3>{{ l('社区统计', 'Community Stats') }}</h3>
<div class="side-stats"> <div class="side-stats">
<div><span>用户</span><strong>{{ sb.total_users }}</strong></div> <div><span>{{ l('用户', 'Users') }}</span><strong>{{ sb.total_users }}</strong></div>
<div><span>帖子</span><strong>{{ sb.total_posts }}</strong></div> <div><span>{{ l('帖子', 'Posts') }}</span><strong>{{ sb.total_posts }}</strong></div>
<div><span>评论</span><strong>{{ sb.total_comments }}</strong></div> <div><span>{{ l('评论', 'Comments') }}</span><strong>{{ sb.total_comments }}</strong></div>
</div> </div>
</div> </div>
<div class="side-card"> <div class="side-card">
<h3>分类热度</h3> <h3>{{ l('分类热度', 'Category Heat') }}</h3>
{% if sb.category_counts %} {% if sb.category_counts %}
<ul class="side-list"> <ul class="side-list">
{% for name, count in sb.category_counts %} {% for name, count in sb.category_counts %}
@@ -169,11 +204,11 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<p class="side-empty">暂无分类数据</p> <p class="side-empty">{{ l('暂无分类数据', 'No category data') }}</p>
{% endif %} {% endif %}
</div> </div>
<div class="side-card"> <div class="side-card">
<h3>活跃作者</h3> <h3>{{ l('活跃作者', 'Active Authors') }}</h3>
{% if sb.active_users %} {% if sb.active_users %}
<ul class="side-list"> <ul class="side-list">
{% for username, post_count in sb.active_users %} {% for username, post_count in sb.active_users %}
@@ -181,7 +216,7 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<p class="side-empty">暂无活跃作者</p> <p class="side-empty">{{ l('暂无活跃作者', 'No active authors') }}</p>
{% endif %} {% endif %}
</div> </div>
</aside> </aside>

View File

@@ -1,9 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{ 'zh-CN' if lang == 'zh' else 'en' }}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>通知中心 - 论坛</title> <title>{{ l('通知中心', 'Notifications') }} - {{ l('论坛', 'Forum') }}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='img/site-logo-mark.svg') }}">
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/forum.css"> <link rel="stylesheet" href="/static/css/forum.css">
</head> </head>
@@ -11,17 +12,25 @@
<header class="forum-header"> <header class="forum-header">
<div class="forum-header-inner"> <div class="forum-header-inner">
<div class="forum-header-left"> <div class="forum-header-left">
<a href="{{ url_for('forum_index') }}" class="forum-logo">VPS 论坛</a> <a href="{{ url_for('forum_index') }}" class="forum-logo">
<img src="{{ url_for('static', filename='img/site-logo-mark.svg') }}" alt="{{ l('云价眼 Logo', 'VPS Price Logo') }}">
<span>{{ l('云价眼论坛', 'VPS Price Forum') }}</span>
</a>
<nav class="forum-primary-nav"> <nav class="forum-primary-nav">
<a href="{{ url_for('forum_index') }}">最新</a> <a href="{{ url_for('forum_index') }}">{{ l('最新', 'Latest') }}</a>
<a href="{{ url_for('index') }}">价格表</a> <a href="{{ url_for('index') }}">{{ l('价格表', 'Pricing') }}</a>
<a href="{{ url_for('user_profile') }}">个人中心</a> <a href="{{ url_for('user_profile') }}">{{ l('个人中心', 'Profile') }}</a>
<a href="{{ url_for('user_notifications') }}" class="active nav-link-with-badge">通知{% if notifications_unread_count %}<span class="nav-badge">{{ notifications_unread_count }}</span>{% endif %}</a> <a href="{{ url_for('user_notifications') }}" class="active nav-link-with-badge">{{ l('通知', 'Notifications') }}{% if notifications_unread_count %}<span class="nav-badge">{{ notifications_unread_count }}</span>{% endif %}</a>
</nav> </nav>
</div> </div>
<div class="forum-header-right"> <div class="forum-header-right">
<span class="lang-switch">
<a href="{{ lang_url('zh') }}" class="{{ 'active' if lang == 'zh' else '' }}" title="切换到中文">中文</a>
<span class="lang-sep">|</span>
<a href="{{ lang_url('en') }}" class="{{ 'active' if lang == 'en' else '' }}" title="Switch to English">English</a>
</span>
<span class="forum-user-chip">{{ current_user.username }}</span> <span class="forum-user-chip">{{ current_user.username }}</span>
<a href="{{ url_for('user_logout') }}" class="forum-link">退出</a> <a href="{{ url_for('user_logout') }}" class="forum-link">{{ l('退出', 'Logout') }}</a>
</div> </div>
</div> </div>
</header> </header>
@@ -30,25 +39,25 @@
<section class="profile-layout"> <section class="profile-layout">
<div class="profile-main"> <div class="profile-main">
<article class="topic-post-card profile-summary"> <article class="topic-post-card profile-summary">
<h1>通知中心</h1> <h1>{{ l('通知中心', 'Notifications') }}</h1>
<p class="editor-subtitle">查看别人对你帖子/评论的互动,以及举报处理结果。</p> <p class="editor-subtitle">{{ l('查看别人对你帖子/评论的互动,以及举报处理结果。', 'Track interactions on your posts/comments and report outcomes.') }}</p>
<div class="profile-stat-grid"> <div class="profile-stat-grid">
<div><span>总通知</span><strong>{{ total_count }}</strong></div> <div><span>{{ l('总通知', 'Total') }}</span><strong>{{ total_count }}</strong></div>
<div><span>未读</span><strong>{{ unread_count }}</strong></div> <div><span>{{ l('未读', 'Unread') }}</span><strong>{{ unread_count }}</strong></div>
<div><span>已读</span><strong>{{ read_count }}</strong></div> <div><span>{{ l('已读', 'Read') }}</span><strong>{{ read_count }}</strong></div>
<div><span>当前筛选</span><strong>{{ '未读' if active_status == 'unread' else '已读' if active_status == 'read' else '全部' }}</strong></div> <div><span>{{ l('当前筛选', 'Current Filter') }}</span><strong>{{ l('未读', 'Unread') if active_status == 'unread' else l('已读', 'Read') if active_status == 'read' else l('全部', 'All') }}</strong></div>
</div> </div>
</article> </article>
<section class="topic-post-card"> <section class="topic-post-card">
<div class="notification-topline"> <div class="notification-topline">
<div class="forum-tabs profile-tabs"> <div class="forum-tabs profile-tabs">
<a href="{{ url_for('user_notifications', status='all') }}" class="{{ 'active' if active_status == 'all' else '' }}">全部</a> <a href="{{ url_for('user_notifications', status='all') }}" class="{{ 'active' if active_status == 'all' else '' }}">{{ l('全部', 'All') }}</a>
<a href="{{ url_for('user_notifications', status='unread') }}" class="{{ 'active' if active_status == 'unread' else '' }}">未读</a> <a href="{{ url_for('user_notifications', status='unread') }}" class="{{ 'active' if active_status == 'unread' else '' }}">{{ l('未读', 'Unread') }}</a>
<a href="{{ url_for('user_notifications', status='read') }}" class="{{ 'active' if active_status == 'read' else '' }}">已读</a> <a href="{{ url_for('user_notifications', status='read') }}" class="{{ 'active' if active_status == 'read' else '' }}">{{ l('已读', 'Read') }}</a>
</div> </div>
<form method="post" action="{{ url_for('user_notifications_read_all') }}"> <form method="post" action="{{ url_for('user_notifications_read_all') }}">
<button type="submit" class="forum-btn-muted">全部标记已读</button> <button type="submit" class="forum-btn-muted">{{ l('全部标记已读', 'Mark All Read') }}</button>
</form> </form>
</div> </div>
@@ -66,21 +75,21 @@
<li class="notification-row {{ '' if n.is_read else 'unread' }}"> <li class="notification-row {{ '' if n.is_read else 'unread' }}">
<div class="notification-body"> <div class="notification-body">
<div class="notification-head"> <div class="notification-head">
<span class="status-pill {{ 'active' if not n.is_read else 'inactive' }}">{{ '未读' if not n.is_read else '已读' }}</span> <span class="status-pill {{ 'active' if not n.is_read else 'inactive' }}">{{ l('未读', 'Unread') if not n.is_read else l('已读', 'Read') }}</span>
<span class="topic-category">{{ item.type_label }}</span> <span class="topic-category">{{ item.type_label }}</span>
<span>{{ item.time_text }}</span> <span>{{ item.time_text }}</span>
{% if item.actor_name %} {% if item.actor_name %}
<span>来自 {{ item.actor_name }}</span> <span>{{ l('来自', 'From') }} {{ item.actor_name }}</span>
{% endif %} {% endif %}
</div> </div>
<div class="notification-message">{{ n.message }}</div> <div class="notification-message">{{ n.message }}</div>
</div> </div>
<div class="notification-actions"> <div class="notification-actions">
<a href="{{ url_for('user_notification_go', notification_id=n.id) }}" class="forum-btn-primary">查看</a> <a href="{{ url_for('user_notification_go', notification_id=n.id) }}" class="forum-btn-primary">{{ l('查看', 'Open') }}</a>
{% if not n.is_read %} {% if not n.is_read %}
<form method="post" action="{{ url_for('user_notification_read', notification_id=n.id) }}"> <form method="post" action="{{ url_for('user_notification_read', notification_id=n.id) }}">
<input type="hidden" name="next" value="{{ request.full_path if request.query_string else request.path }}"> <input type="hidden" name="next" value="{{ request.full_path if request.query_string else request.path }}">
<button type="submit" class="forum-btn-muted">标记已读</button> <button type="submit" class="forum-btn-muted">{{ l('标记已读', 'Mark Read') }}</button>
</form> </form>
{% endif %} {% endif %}
</div> </div>
@@ -88,27 +97,27 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<p class="topic-empty">当前没有通知。</p> <p class="topic-empty">{{ l('当前没有通知。', 'No notifications right now.') }}</p>
{% endif %} {% endif %}
</section> </section>
</div> </div>
<aside class="forum-sidebar"> <aside class="forum-sidebar">
<div class="side-card"> <div class="side-card">
<h3>通知说明</h3> <h3>{{ l('通知说明', 'Notification Types') }}</h3>
<ul class="side-list"> <ul class="side-list">
<li><span>帖子新评论</span><strong>别人评论你的帖子</strong></li> <li><span>{{ l('帖子新评论', 'New comment') }}</span><strong>{{ l('别人评论你的帖子', 'Someone commented on your topic') }}</strong></li>
<li><span>主题新回复</span><strong>别人回复你参与的主题</strong></li> <li><span>{{ l('主题新回复', 'New reply') }}</span><strong>{{ l('别人回复你参与的主题', 'Someone replied to a topic you joined') }}</strong></li>
<li><span>举报处理结果</span><strong>你发起的举报被处理</strong></li> <li><span>{{ l('举报处理结果', 'Report update') }}</span><strong>{{ l('你发起的举报被处理', 'Your submitted report was processed') }}</strong></li>
<li><span>内容处理通知</span><strong>你的内容被处理</strong></li> <li><span>{{ l('内容处理通知', 'Content moderation') }}</span><strong>{{ l('你的内容被处理', 'Your content was moderated') }}</strong></li>
</ul> </ul>
</div> </div>
<div class="side-card"> <div class="side-card">
<h3>快捷操作</h3> <h3>{{ l('快捷操作', 'Quick Actions') }}</h3>
<div class="form-actions"> <div class="form-actions">
<a href="{{ url_for('forum_post_new') }}" class="forum-btn-primary">发布主题</a> <a href="{{ url_for('forum_post_new') }}" class="forum-btn-primary">{{ l('发布主题', 'New Topic') }}</a>
<a href="{{ url_for('user_profile') }}" class="forum-btn-muted">返回个人中心</a> <a href="{{ url_for('user_profile') }}" class="forum-btn-muted">{{ l('返回个人中心', 'Back to Profile') }}</a>
<a href="{{ url_for('forum_index') }}" class="forum-btn-muted">返回论坛</a> <a href="{{ url_for('forum_index') }}" class="forum-btn-muted">{{ l('返回论坛', 'Back to Forum') }}</a>
</div> </div>
</div> </div>
</aside> </aside>

View File

@@ -1,9 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{ 'zh-CN' if lang == 'zh' else 'en' }}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ post.title }} - 论坛</title> <title>{{ post.title }} - {{ l('论坛', 'Forum') }}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='img/site-logo-mark.svg') }}">
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/forum.css"> <link rel="stylesheet" href="/static/css/forum.css">
</head> </head>
@@ -12,24 +13,32 @@
<header class="forum-header"> <header class="forum-header">
<div class="forum-header-inner"> <div class="forum-header-inner">
<div class="forum-header-left"> <div class="forum-header-left">
<a href="{{ url_for('forum_index') }}" class="forum-logo">VPS 论坛</a> <a href="{{ url_for('forum_index') }}" class="forum-logo">
<img src="{{ url_for('static', filename='img/site-logo-mark.svg') }}" alt="{{ l('云价眼 Logo', 'VPS Price Logo') }}">
<span>{{ l('云价眼论坛', 'VPS Price Forum') }}</span>
</a>
<nav class="forum-primary-nav"> <nav class="forum-primary-nav">
<a href="{{ url_for('forum_index') }}" class="active">最新</a> <a href="{{ url_for('forum_index') }}" class="active">{{ l('最新', 'Latest') }}</a>
<a href="{{ url_for('index') }}">价格表</a> <a href="{{ url_for('index') }}">{{ l('价格表', 'Pricing') }}</a>
{% if current_user and not current_user.is_banned %} {% if current_user and not current_user.is_banned %}
<a href="{{ url_for('forum_post_new') }}">发布主题</a> <a href="{{ url_for('forum_post_new') }}">{{ l('发布主题', 'New Topic') }}</a>
{% endif %} {% endif %}
</nav> </nav>
</div> </div>
<div class="forum-header-right"> <div class="forum-header-right">
<span class="lang-switch">
<a href="{{ lang_url('zh') }}" class="{{ 'active' if lang == 'zh' else '' }}" title="切换到中文">中文</a>
<span class="lang-sep">|</span>
<a href="{{ lang_url('en') }}" class="{{ 'active' if lang == 'en' else '' }}" title="Switch to English">English</a>
</span>
{% if current_user %} {% if current_user %}
<span class="forum-user-chip">{{ current_user.username }}{% if current_user.is_banned %}(封禁){% endif %}</span> <span class="forum-user-chip">{{ current_user.username }}{% if current_user.is_banned %}{{ l('(封禁)', ' (Banned)') }}{% endif %}</span>
<a href="{{ url_for('user_profile') }}" class="forum-link">个人中心</a> <a href="{{ url_for('user_profile') }}" class="forum-link">{{ l('个人中心', 'Profile') }}</a>
<a href="{{ url_for('user_notifications') }}" class="forum-link nav-link-with-badge">通知{% if notifications_unread_count %}<span class="nav-badge">{{ notifications_unread_count }}</span>{% endif %}</a> <a href="{{ url_for('user_notifications') }}" class="forum-link nav-link-with-badge">{{ l('通知', 'Notifications') }}{% if notifications_unread_count %}<span class="nav-badge">{{ notifications_unread_count }}</span>{% endif %}</a>
<a href="{{ url_for('user_logout') }}" class="forum-link">退出</a> <a href="{{ url_for('user_logout') }}" class="forum-link">{{ l('退出', 'Logout') }}</a>
{% else %} {% else %}
<a href="{{ url_for('user_login', next=request.path) }}" class="forum-link">登录</a> <a href="{{ url_for('user_login', next=request.path) }}" class="forum-link">{{ l('登录', 'Login') }}</a>
<a href="{{ url_for('user_register', next=request.path) }}" class="forum-link">注册</a> <a href="{{ url_for('user_register', next=request.path) }}" class="forum-link">{{ l('注册', 'Register') }}</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -40,37 +49,37 @@
<div class="topic-stream"> <div class="topic-stream">
<article class="topic-post-card"> <article class="topic-post-card">
<div class="topic-post-head"> <div class="topic-post-head">
<span class="topic-category">{{ post.category or '综合讨论' }}</span> <span class="topic-category">{{ post.category or l('综合讨论', 'General') }}</span>
{% if post.is_pinned %}<span class="topic-flag flag-pinned">置顶</span>{% endif %} {% if post.is_pinned %}<span class="topic-flag flag-pinned">{{ l('置顶', 'Pinned') }}</span>{% endif %}
{% if post.is_featured %}<span class="topic-flag flag-featured">精华</span>{% endif %} {% if post.is_featured %}<span class="topic-flag flag-featured">{{ l('精华', 'Featured') }}</span>{% endif %}
{% if post.is_locked %}<span class="topic-flag flag-locked">锁帖</span>{% endif %} {% if post.is_locked %}<span class="topic-flag flag-locked">{{ l('锁帖', 'Locked') }}</span>{% endif %}
<span>{{ post.created_at.strftime('%Y-%m-%d %H:%M') if post.created_at else '' }}</span> <span>{{ post.created_at.strftime('%Y-%m-%d %H:%M') if post.created_at else '' }}</span>
<span>浏览 {{ post.view_count or 0 }}</span> <span>{{ l('浏览', 'Views') }} {{ post.view_count or 0 }}</span>
<span>点赞 {{ like_count or 0 }}</span> <span>{{ l('点赞', 'Likes') }} {{ like_count or 0 }}</span>
<span>收藏 {{ bookmark_count or 0 }}</span> <span>{{ l('收藏', 'Bookmarks') }} {{ bookmark_count or 0 }}</span>
</div> </div>
<h1>{{ post.title }}</h1> <h1>{{ post.title }}</h1>
<div class="topic-post-author">作者:{{ post.author_rel.username if post.author_rel else '已注销用户' }}</div> <div class="topic-post-author">{{ l('作者:', 'Author: ') }}{{ post.author_rel.username if post.author_rel else l('已注销用户', 'Deleted user') }}</div>
<div class="topic-action-bar"> <div class="topic-action-bar">
{% if current_user and can_interact %} {% if current_user and can_interact %}
<form method="post" action="{{ url_for('forum_post_like_toggle', post_id=post.id) }}"> <form method="post" action="{{ url_for('forum_post_like_toggle', post_id=post.id) }}">
<button type="submit" class="forum-btn-muted {{ 'active' if liked_by_me else '' }}">{{ '已点赞' if liked_by_me else '点赞' }}</button> <button type="submit" class="forum-btn-muted {{ 'active' if liked_by_me else '' }}">{{ l('已点赞', 'Liked') if liked_by_me else l('点赞', 'Like') }}</button>
</form> </form>
<form method="post" action="{{ url_for('forum_post_bookmark_toggle', post_id=post.id) }}"> <form method="post" action="{{ url_for('forum_post_bookmark_toggle', post_id=post.id) }}">
<button type="submit" class="forum-btn-muted {{ 'active' if bookmarked_by_me else '' }}">{{ '已收藏' if bookmarked_by_me else '收藏' }}</button> <button type="submit" class="forum-btn-muted {{ 'active' if bookmarked_by_me else '' }}">{{ l('已收藏', 'Bookmarked') if bookmarked_by_me else l('收藏', 'Bookmark') }}</button>
</form> </form>
{% elif current_user and not can_interact %} {% elif current_user and not can_interact %}
<span class="topic-empty">账号被封禁,暂不可互动</span> <span class="topic-empty">{{ l('账号被封禁,暂不可互动', 'Your account is banned. Interaction is disabled.') }}</span>
{% else %} {% else %}
<a href="{{ url_for('user_login', next=request.path) }}" class="forum-btn-muted">登录后点赞/收藏</a> <a href="{{ url_for('user_login', next=request.path) }}" class="forum-btn-muted">{{ l('登录后点赞/收藏', 'Login to like/bookmark') }}</a>
{% endif %} {% endif %}
{% if current_user and current_user.id == post.user_id and can_interact %} {% if current_user and current_user.id == post.user_id and can_interact %}
<a href="{{ url_for('forum_post_edit', post_id=post.id) }}" class="forum-btn-muted">编辑帖子</a> <a href="{{ url_for('forum_post_edit', post_id=post.id) }}" class="forum-btn-muted">{{ l('编辑帖子', 'Edit Topic') }}</a>
<form method="post" action="{{ url_for('forum_post_delete', post_id=post.id) }}" onsubmit="return confirm('确定删除该帖子?删除后不可恢复。');"> <form method="post" action="{{ url_for('forum_post_delete', post_id=post.id) }}" onsubmit="return confirm('{{ l('确定删除该帖子?删除后不可恢复。', 'Delete this topic permanently?') }}');">
<button type="submit" class="forum-btn-danger">删除帖子</button> <button type="submit" class="forum-btn-danger">{{ l('删除帖子', 'Delete Topic') }}</button>
</form> </form>
{% elif current_user and current_user.id == post.user_id and not can_interact %} {% elif current_user and current_user.id == post.user_id and not can_interact %}
<span class="topic-empty">账号封禁中,无法编辑或删除帖子</span> <span class="topic-empty">{{ l('账号封禁中,无法编辑或删除帖子', 'Account banned. Editing/deleting is disabled.') }}</span>
{% elif current_user and can_interact %} {% elif current_user and can_interact %}
<form method="post" action="{{ url_for('forum_report_create') }}" class="report-form-inline"> <form method="post" action="{{ url_for('forum_report_create') }}" class="report-form-inline">
<input type="hidden" name="target_type" value="post"> <input type="hidden" name="target_type" value="post">
@@ -80,15 +89,15 @@
<option value="{{ reason }}">{{ reason }}</option> <option value="{{ reason }}">{{ reason }}</option>
{% endfor %} {% endfor %}
</select> </select>
<button type="submit" class="forum-btn-muted">举报帖子</button> <button type="submit" class="forum-btn-muted">{{ l('举报帖子', 'Report Topic') }}</button>
</form> </form>
{% endif %} {% endif %}
</div> </div>
<div class="topic-post-content">{{ post.content }}</div> <div class="topic-post-content md-content">{{ post.content|markdown_html }}</div>
</article> </article>
<section class="topic-post-card"> <section class="topic-post-card">
<h2>评论{{ comments|length }}</h2> <h2>{{ l('评论', 'Comments') }}{{ comments|length }}</h2>
{% if message %} {% if message %}
<p class="form-success">{{ message }}</p> <p class="form-success">{{ message }}</p>
{% endif %} {% endif %}
@@ -97,19 +106,20 @@
{% endif %} {% endif %}
{% if post.is_locked %} {% if post.is_locked %}
<p class="topic-empty">该帖子已锁定,暂不允许新增评论。</p> <p class="topic-empty">{{ l('该帖子已锁定,暂不允许新增评论。', 'This topic is locked. New comments are disabled.') }}</p>
{% elif current_user and can_interact %} {% elif current_user and can_interact %}
<form method="post" action="{{ url_for('forum_post_comment', post_id=post.id) }}" class="comment-form"> <form method="post" action="{{ url_for('forum_post_comment', post_id=post.id) }}" class="comment-form">
<div class="form-group"> <div class="form-group">
<label for="content">写下你的评论</label> <label for="content">{{ l('写下你的评论', 'Write your comment') }}</label>
<textarea id="content" name="content" required rows="4" minlength="2"></textarea> <textarea id="content" name="content" required rows="4" minlength="2"></textarea>
<small class="form-help">{{ l('支持 Markdown 代码块。', 'Markdown code blocks are supported.') }}</small>
</div> </div>
<button type="submit" class="forum-btn-primary">发布评论</button> <button type="submit" class="forum-btn-primary">{{ l('发布评论', 'Post Comment') }}</button>
</form> </form>
{% elif current_user and not can_interact %} {% elif current_user and not can_interact %}
<p class="topic-empty">账号被封禁,暂不可评论。</p> <p class="topic-empty">{{ l('账号被封禁,暂不可评论。', 'Your account is banned. Commenting is disabled.') }}</p>
{% else %} {% else %}
<p class="topic-empty">请先 <a href="{{ url_for('user_login', next=request.path) }}">登录</a> 后评论。</p> <p class="topic-empty">{{ l('请先', 'Please') }} <a href="{{ url_for('user_login', next=request.path) }}">{{ l('登录', 'log in') }}</a> {{ l('后评论。', 'to comment.') }}</p>
{% endif %} {% endif %}
{% if comments %} {% if comments %}
@@ -119,18 +129,18 @@
<div class="comment-avatar">{{ (c.author_rel.username[0] if c.author_rel and c.author_rel.username else '?')|upper }}</div> <div class="comment-avatar">{{ (c.author_rel.username[0] if c.author_rel and c.author_rel.username else '?')|upper }}</div>
<div class="comment-body"> <div class="comment-body">
<div class="comment-head"> <div class="comment-head">
<span class="comment-author">{{ c.author_rel.username if c.author_rel else '已注销用户' }}</span> <span class="comment-author">{{ c.author_rel.username if c.author_rel else l('已注销用户', 'Deleted user') }}</span>
<span>{{ c.created_at.strftime('%Y-%m-%d %H:%M') if c.created_at else '' }}</span> <span>{{ c.created_at.strftime('%Y-%m-%d %H:%M') if c.created_at else '' }}</span>
</div> </div>
<div class="comment-content">{{ c.content }}</div> <div class="comment-content md-content">{{ c.content|markdown_html }}</div>
<div class="comment-actions"> <div class="comment-actions">
{% if current_user and current_user.id == c.user_id and can_interact %} {% if current_user and current_user.id == c.user_id and can_interact %}
<a href="{{ url_for('forum_comment_edit', comment_id=c.id) }}">编辑</a> <a href="{{ url_for('forum_comment_edit', comment_id=c.id) }}">{{ l('编辑', 'Edit') }}</a>
<form method="post" action="{{ url_for('forum_comment_delete', comment_id=c.id) }}" onsubmit="return confirm('确定删除这条评论?');"> <form method="post" action="{{ url_for('forum_comment_delete', comment_id=c.id) }}" onsubmit="return confirm('{{ l('确定删除这条评论?', 'Delete this comment?') }}');">
<button type="submit" class="btn-link-delete">删除</button> <button type="submit" class="btn-link-delete">{{ l('删除', 'Delete') }}</button>
</form> </form>
{% elif current_user and current_user.id == c.user_id and not can_interact %} {% elif current_user and current_user.id == c.user_id and not can_interact %}
<span class="muted-text">账号封禁中</span> <span class="muted-text">{{ l('账号封禁中', 'Account banned') }}</span>
{% elif current_user and can_interact %} {% elif current_user and can_interact %}
<form method="post" action="{{ url_for('forum_report_create') }}" class="report-form-inline"> <form method="post" action="{{ url_for('forum_report_create') }}" class="report-form-inline">
<input type="hidden" name="target_type" value="comment"> <input type="hidden" name="target_type" value="comment">
@@ -140,7 +150,7 @@
<option value="{{ reason }}">{{ reason }}</option> <option value="{{ reason }}">{{ reason }}</option>
{% endfor %} {% endfor %}
</select> </select>
<button type="submit" class="btn-link-delete">举报</button> <button type="submit" class="btn-link-delete">{{ l('举报', 'Report') }}</button>
</form> </form>
{% endif %} {% endif %}
</div> </div>
@@ -149,22 +159,22 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<p class="topic-empty">还没有评论,欢迎抢沙发。</p> <p class="topic-empty">{{ l('还没有评论,欢迎抢沙发。', 'No comments yet. Be the first to reply.') }}</p>
{% endif %} {% endif %}
</section> </section>
</div> </div>
<aside class="forum-sidebar"> <aside class="forum-sidebar">
<div class="side-card"> <div class="side-card">
<h3>社区统计</h3> <h3>{{ l('社区统计', 'Community Stats') }}</h3>
<div class="side-stats"> <div class="side-stats">
<div><span>用户</span><strong>{{ sb.total_users }}</strong></div> <div><span>{{ l('用户', 'Users') }}</span><strong>{{ sb.total_users }}</strong></div>
<div><span>帖子</span><strong>{{ sb.total_posts }}</strong></div> <div><span>{{ l('帖子', 'Posts') }}</span><strong>{{ sb.total_posts }}</strong></div>
<div><span>评论</span><strong>{{ sb.total_comments }}</strong></div> <div><span>{{ l('评论', 'Comments') }}</span><strong>{{ sb.total_comments }}</strong></div>
</div> </div>
</div> </div>
<div class="side-card"> <div class="side-card">
<h3>分类热度</h3> <h3>{{ l('分类热度', 'Category Heat') }}</h3>
{% if sb.category_counts %} {% if sb.category_counts %}
<ul class="side-list"> <ul class="side-list">
{% for name, count in sb.category_counts %} {% for name, count in sb.category_counts %}
@@ -172,7 +182,7 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<p class="side-empty">暂无分类数据</p> <p class="side-empty">{{ l('暂无分类数据', 'No category data') }}</p>
{% endif %} {% endif %}
</div> </div>
</aside> </aside>

View File

@@ -1,33 +1,42 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{ 'zh-CN' if lang == 'zh' else 'en' }}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>发布帖子 - 云价眼</title> <title>{{ l('发布帖子', 'Topic Editor') }} - {{ l('云价眼', 'VPS Price') }}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='img/site-logo-mark.svg') }}">
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/forum.css"> <link rel="stylesheet" href="/static/css/forum.css">
</head> </head>
<body class="forum-page"> <body class="forum-page">
{% set category_options = categories if categories is defined and categories else forum_categories if forum_categories is defined and forum_categories else ['综合讨论', 'VPS 评测', '优惠活动', '运维经验', '新手提问'] %} {% 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_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 '发布主题' %} {% 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_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_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' %} {% set p_mode = form_mode if form_mode is defined else 'create' %}
<header class="forum-header"> <header class="forum-header">
<div class="forum-header-inner"> <div class="forum-header-inner">
<div class="forum-header-left"> <div class="forum-header-left">
<a href="{{ url_for('forum_index') }}" class="forum-logo">VPS 论坛</a> <a href="{{ url_for('forum_index') }}" class="forum-logo">
<img src="{{ url_for('static', filename='img/site-logo-mark.svg') }}" alt="{{ l('云价眼 Logo', 'VPS Price Logo') }}">
<span>{{ l('云价眼论坛', 'VPS Price Forum') }}</span>
</a>
<nav class="forum-primary-nav"> <nav class="forum-primary-nav">
<a href="{{ url_for('forum_index') }}" class="{{ 'active' if p_mode == 'create' else '' }}">最新</a> <a href="{{ url_for('forum_index') }}" class="{{ 'active' if p_mode == 'create' else '' }}">{{ l('最新', 'Latest') }}</a>
<a href="{{ url_for('index') }}">价格表</a> <a href="{{ url_for('index') }}">{{ l('价格表', 'Pricing') }}</a>
</nav> </nav>
</div> </div>
<div class="forum-header-right"> <div class="forum-header-right">
<span class="lang-switch">
<a href="{{ lang_url('zh') }}" class="{{ 'active' if lang == 'zh' else '' }}" title="切换到中文">中文</a>
<span class="lang-sep">|</span>
<a href="{{ lang_url('en') }}" class="{{ 'active' if lang == 'en' else '' }}" title="Switch to English">English</a>
</span>
<span class="forum-user-chip">{{ current_user.username }}</span> <span class="forum-user-chip">{{ current_user.username }}</span>
<a href="{{ url_for('user_profile') }}" class="forum-link">个人中心</a> <a href="{{ url_for('user_profile') }}" class="forum-link">{{ l('个人中心', 'Profile') }}</a>
<a href="{{ url_for('user_notifications') }}" class="forum-link nav-link-with-badge">通知{% if notifications_unread_count %}<span class="nav-badge">{{ notifications_unread_count }}</span>{% endif %}</a> <a href="{{ url_for('user_notifications') }}" class="forum-link nav-link-with-badge">{{ l('通知', 'Notifications') }}{% if notifications_unread_count %}<span class="nav-badge">{{ notifications_unread_count }}</span>{% endif %}</a>
<a href="{{ url_for('user_logout') }}" class="forum-link">退出</a> <a href="{{ url_for('user_logout') }}" class="forum-link">{{ l('退出', 'Logout') }}</a>
</div> </div>
</div> </div>
</header> </header>
@@ -38,9 +47,9 @@
<h1>{{ p_title }}</h1> <h1>{{ p_title }}</h1>
<p class="editor-subtitle"> <p class="editor-subtitle">
{% if p_mode == 'edit' %} {% if p_mode == 'edit' %}
修改标题、分类或正文后保存,帖子会按最新活动时间排序。 {{ l('修改标题、分类或正文后保存,帖子会按最新活动时间排序。', 'Update title/category/content and save. The topic will be sorted by latest activity.') }}
{% else %} {% else %}
描述你的问题、评测或优惠信息,方便其他用户快速理解。 {{ l('描述你的问题、评测或优惠信息,方便其他用户快速理解。', 'Describe your question, review, or deal details for other users.') }}
{% endif %} {% endif %}
</p> </p>
{% if error %} {% if error %}
@@ -48,7 +57,7 @@
{% endif %} {% endif %}
<form method="post" action="{{ p_action }}" class="post-form"> <form method="post" action="{{ p_action }}" class="post-form">
<div class="form-group"> <div class="form-group">
<label for="category">分类</label> <label for="category">{{ l('分类', 'Category') }}</label>
<select id="category" name="category" required> <select id="category" name="category" required>
{% for c in category_options %} {% for c in category_options %}
<option value="{{ c }}" {{ 'selected' if c == category_val else '' }}>{{ c }}</option> <option value="{{ c }}" {{ 'selected' if c == category_val else '' }}>{{ c }}</option>
@@ -56,26 +65,27 @@
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="title">标题</label> <label for="title">{{ l('标题', 'Title') }}</label>
<input id="title" name="title" type="text" required maxlength="160" value="{{ title_val or '' }}" placeholder="例如2核4G VPS 哪家性价比高?"> <input id="title" name="title" type="text" required maxlength="160" value="{{ title_val or '' }}" placeholder="{{ l('例如2核4G VPS 哪家性价比高?', 'Example: Which 2C4G VPS has the best value?') }}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="content">正文</label> <label for="content">{{ l('正文', 'Content') }}</label>
<textarea id="content" name="content" required rows="14" placeholder="请描述你的需求、配置、预算、地区、用途等信息。">{{ content_val or '' }}</textarea> <textarea id="content" name="content" required rows="14" placeholder="{{ l('请描述你的需求、配置、预算、地区、用途等信息。', 'Describe your needs: spec, budget, region, and usage.') }}">{{ content_val or '' }}</textarea>
<small class="form-help">{{ l('支持 Markdown代码块可用 ```language ... ```', 'Markdown supported. Use ```language ... ``` for code blocks.') }}</small>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="forum-btn-primary">{{ p_submit }}</button> <button type="submit" class="forum-btn-primary">{{ p_submit }}</button>
<a href="{{ p_cancel }}" class="forum-btn-muted">取消</a> <a href="{{ p_cancel }}" class="forum-btn-muted">{{ l('取消', 'Cancel') }}</a>
</div> </div>
</form> </form>
</article> </article>
<aside class="side-card post-helper"> <aside class="side-card post-helper">
<h3>发帖建议</h3> <h3>{{ l('发帖建议', 'Posting Tips') }}</h3>
<ul class="helper-list"> <ul class="helper-list">
<li>标题尽量包含配置、预算和地区关键词。</li> <li>{{ l('标题尽量包含配置、预算和地区关键词。', 'Put spec, budget, and region keywords in the title.') }}</li>
<li>正文建议写明:用途、目标线路、可接受价格。</li> <li>{{ l('正文建议写明:用途、目标线路、可接受价格。', 'In content, include usage, target route, and acceptable price.') }}</li>
<li>若有实测数据,可附上延迟、带宽或稳定性说明。</li> <li>{{ l('若有实测数据,可附上延迟、带宽或稳定性说明。', 'If you have real test data, add latency/bandwidth/stability notes.') }}</li>
<li>避免发布无关广告或重复内容。</li> <li>{{ l('避免发布无关广告或重复内容。', 'Avoid unrelated ads or duplicate content.') }}</li>
</ul> </ul>
</aside> </aside>
</section> </section>

View File

@@ -1,9 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{ 'zh-CN' if lang == 'zh' else 'en' }}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>个人中心 - 论坛</title> <title>{{ l('个人中心', 'Profile') }} - {{ l('论坛', 'Forum') }}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='img/site-logo-mark.svg') }}">
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/forum.css"> <link rel="stylesheet" href="/static/css/forum.css">
</head> </head>
@@ -11,17 +12,25 @@
<header class="forum-header"> <header class="forum-header">
<div class="forum-header-inner"> <div class="forum-header-inner">
<div class="forum-header-left"> <div class="forum-header-left">
<a href="{{ url_for('forum_index') }}" class="forum-logo">VPS 论坛</a> <a href="{{ url_for('forum_index') }}" class="forum-logo">
<img src="{{ url_for('static', filename='img/site-logo-mark.svg') }}" alt="{{ l('云价眼 Logo', 'VPS Price Logo') }}">
<span>{{ l('云价眼论坛', 'VPS Price Forum') }}</span>
</a>
<nav class="forum-primary-nav"> <nav class="forum-primary-nav">
<a href="{{ url_for('forum_index') }}">最新</a> <a href="{{ url_for('forum_index') }}">{{ l('最新', 'Latest') }}</a>
<a href="{{ url_for('index') }}">价格表</a> <a href="{{ url_for('index') }}">{{ l('价格表', 'Pricing') }}</a>
<a href="{{ url_for('user_profile') }}" class="active">个人中心</a> <a href="{{ url_for('user_profile') }}" class="active">{{ l('个人中心', 'Profile') }}</a>
<a href="{{ url_for('user_notifications') }}" class="nav-link-with-badge">通知{% if notifications_unread_count %}<span class="nav-badge">{{ notifications_unread_count }}</span>{% endif %}</a> <a href="{{ url_for('user_notifications') }}" class="nav-link-with-badge">{{ l('通知', 'Notifications') }}{% if notifications_unread_count %}<span class="nav-badge">{{ notifications_unread_count }}</span>{% endif %}</a>
</nav> </nav>
</div> </div>
<div class="forum-header-right"> <div class="forum-header-right">
<span class="lang-switch">
<a href="{{ lang_url('zh') }}" class="{{ 'active' if lang == 'zh' else '' }}" title="切换到中文">中文</a>
<span class="lang-sep">|</span>
<a href="{{ lang_url('en') }}" class="{{ 'active' if lang == 'en' else '' }}" title="Switch to English">English</a>
</span>
<span class="forum-user-chip">{{ profile_user.username }}</span> <span class="forum-user-chip">{{ profile_user.username }}</span>
<a href="{{ url_for('user_logout') }}" class="forum-link">退出</a> <a href="{{ url_for('user_logout') }}" class="forum-link">{{ l('退出', 'Logout') }}</a>
</div> </div>
</div> </div>
</header> </header>
@@ -30,27 +39,27 @@
<section class="profile-layout"> <section class="profile-layout">
<div class="profile-main"> <div class="profile-main">
<article class="topic-post-card profile-summary"> <article class="topic-post-card profile-summary">
<h1>个人中心</h1> <h1>{{ l('个人中心', 'Profile') }}</h1>
<p class="editor-subtitle">管理你的帖子、评论和账号设置。</p> <p class="editor-subtitle">{{ l('管理你的帖子、评论和账号设置。', 'Manage your topics, comments, and account settings.') }}</p>
<div class="profile-stat-grid"> <div class="profile-stat-grid">
<div><span>帖子</span><strong>{{ stats.post_count }}</strong></div> <div><span>{{ l('帖子', 'Posts') }}</span><strong>{{ stats.post_count }}</strong></div>
<div><span>评论</span><strong>{{ stats.comment_count }}</strong></div> <div><span>{{ l('评论', 'Comments') }}</span><strong>{{ stats.comment_count }}</strong></div>
<div><span>点赞</span><strong>{{ stats.like_count }}</strong></div> <div><span>{{ l('点赞', 'Likes') }}</span><strong>{{ stats.like_count }}</strong></div>
<div><span>收藏</span><strong>{{ stats.bookmark_count }}</strong></div> <div><span>{{ l('收藏', 'Bookmarks') }}</span><strong>{{ stats.bookmark_count }}</strong></div>
<div><span>举报</span><strong>{{ stats.report_count }}</strong></div> <div><span>{{ l('举报', 'Reports') }}</span><strong>{{ stats.report_count }}</strong></div>
<div><span>待处理举报</span><strong>{{ stats.pending_report_count }}</strong></div> <div><span>{{ l('待处理举报', 'Pending Reports') }}</span><strong>{{ stats.pending_report_count }}</strong></div>
<div><span>通知</span><strong>{{ stats.notification_count }}</strong></div> <div><span>{{ l('通知', 'Notifications') }}</span><strong>{{ stats.notification_count }}</strong></div>
<div><span>未读通知</span><strong>{{ stats.unread_notification_count }}</strong></div> <div><span>{{ l('未读通知', 'Unread') }}</span><strong>{{ stats.unread_notification_count }}</strong></div>
</div> </div>
</article> </article>
<section class="topic-post-card"> <section class="topic-post-card">
<div class="forum-tabs profile-tabs"> <div class="forum-tabs profile-tabs">
<a href="{{ url_for('user_profile', tab='posts') }}" class="{{ 'active' if active_tab == 'posts' else '' }}">我的帖子</a> <a href="{{ url_for('user_profile', tab='posts') }}" class="{{ 'active' if active_tab == 'posts' else '' }}">{{ l('我的帖子', 'My Posts') }}</a>
<a href="{{ url_for('user_profile', tab='comments') }}" class="{{ 'active' if active_tab == 'comments' else '' }}">我的评论</a> <a href="{{ url_for('user_profile', tab='comments') }}" class="{{ 'active' if active_tab == 'comments' else '' }}">{{ l('我的评论', 'My Comments') }}</a>
<a href="{{ url_for('user_profile', tab='likes') }}" class="{{ 'active' if active_tab == 'likes' else '' }}">我的点赞</a> <a href="{{ url_for('user_profile', tab='likes') }}" class="{{ 'active' if active_tab == 'likes' else '' }}">{{ l('我的点赞', 'My Likes') }}</a>
<a href="{{ url_for('user_profile', tab='bookmarks') }}" class="{{ 'active' if active_tab == 'bookmarks' else '' }}">我的收藏</a> <a href="{{ url_for('user_profile', tab='bookmarks') }}" class="{{ 'active' if active_tab == 'bookmarks' else '' }}">{{ l('我的收藏', 'My Bookmarks') }}</a>
<a href="{{ url_for('user_profile', tab='settings') }}" class="{{ 'active' if active_tab == 'settings' else '' }}">账号设置</a> <a href="{{ url_for('user_profile', tab='settings') }}" class="{{ 'active' if active_tab == 'settings' else '' }}">{{ l('账号设置', 'Settings') }}</a>
</div> </div>
{% if message %} {% if message %}
@@ -69,23 +78,23 @@
<div class="profile-row-main"> <div class="profile-row-main">
<a href="{{ url_for('forum_post_detail', post_id=post.id) }}" class="topic-title">{{ post.title }}</a> <a href="{{ url_for('forum_post_detail', post_id=post.id) }}" class="topic-title">{{ post.title }}</a>
<div class="topic-meta"> <div class="topic-meta">
<span class="topic-category">{{ post.category or '综合讨论' }}</span> <span class="topic-category">{{ post.category or l('综合讨论', 'General') }}</span>
<span>创建:{{ post.created_at.strftime('%Y-%m-%d %H:%M') if post.created_at else '' }}</span> <span>{{ l('创建:', 'Created: ') }}{{ post.created_at.strftime('%Y-%m-%d %H:%M') if post.created_at else '' }}</span>
<span>回复 {{ item.reply_count }}</span> <span>{{ l('回复', 'Replies') }} {{ item.reply_count }}</span>
<span>浏览 {{ item.view_count }}</span> <span>{{ l('浏览', 'Views') }} {{ item.view_count }}</span>
</div> </div>
</div> </div>
<div class="profile-row-actions"> <div class="profile-row-actions">
<a href="{{ url_for('forum_post_edit', post_id=post.id) }}">编辑</a> <a href="{{ url_for('forum_post_edit', post_id=post.id) }}">{{ l('编辑', 'Edit') }}</a>
<form method="post" action="{{ url_for('forum_post_delete', post_id=post.id) }}" onsubmit="return confirm('确定删除该帖子?');"> <form method="post" action="{{ url_for('forum_post_delete', post_id=post.id) }}" onsubmit="return confirm('{{ l('确定删除该帖子?', 'Delete this topic?') }}');">
<button type="submit" class="btn-link-delete">删除</button> <button type="submit" class="btn-link-delete">{{ l('删除', 'Delete') }}</button>
</form> </form>
</div> </div>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<p class="topic-empty">你还没有发过帖子,先去 <a href="{{ url_for('forum_post_new') }}">发布主题</a> 吧。</p> <p class="topic-empty">{{ l('你还没有发过帖子,先去', 'You have not posted yet. Go') }} <a href="{{ url_for('forum_post_new') }}">{{ l('发布主题', 'create one') }}</a>{{ l('吧。', '.') }}</p>
{% endif %} {% endif %}
{% elif active_tab == 'comments' %} {% elif active_tab == 'comments' %}
{% if my_comment_items %} {% if my_comment_items %}
@@ -94,23 +103,23 @@
{% set c = item.comment %} {% set c = item.comment %}
<li class="profile-row"> <li class="profile-row">
<div class="profile-row-main"> <div class="profile-row-main">
<a href="{{ url_for('forum_post_detail', post_id=item.post_id) }}" class="topic-title">{{ item.post_title or '帖子已删除' }}</a> <a href="{{ url_for('forum_post_detail', post_id=item.post_id) }}" class="topic-title">{{ item.post_title or l('帖子已删除', 'Deleted topic') }}</a>
<div class="topic-meta"> <div class="topic-meta">
<span>评论时间:{{ c.created_at.strftime('%Y-%m-%d %H:%M') if c.created_at else '' }}</span> <span>{{ l('评论时间:', 'Commented: ') }}{{ c.created_at.strftime('%Y-%m-%d %H:%M') if c.created_at else '' }}</span>
</div> </div>
<div class="comment-content">{{ c.content }}</div> <div class="comment-content md-content">{{ c.content|markdown_html }}</div>
</div> </div>
<div class="profile-row-actions"> <div class="profile-row-actions">
<a href="{{ url_for('forum_comment_edit', comment_id=c.id) }}">编辑</a> <a href="{{ url_for('forum_comment_edit', comment_id=c.id) }}">{{ l('编辑', 'Edit') }}</a>
<form method="post" action="{{ url_for('forum_comment_delete', comment_id=c.id) }}" onsubmit="return confirm('确定删除这条评论?');"> <form method="post" action="{{ url_for('forum_comment_delete', comment_id=c.id) }}" onsubmit="return confirm('{{ l('确定删除这条评论?', 'Delete this comment?') }}');">
<button type="submit" class="btn-link-delete">删除</button> <button type="submit" class="btn-link-delete">{{ l('删除', 'Delete') }}</button>
</form> </form>
</div> </div>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<p class="topic-empty">你还没有评论记录。</p> <p class="topic-empty">{{ l('你还没有评论记录。', 'No comments yet.') }}</p>
{% endif %} {% endif %}
{% elif active_tab == 'likes' %} {% elif active_tab == 'likes' %}
{% if my_like_items %} {% if my_like_items %}
@@ -120,22 +129,22 @@
<div class="profile-row-main"> <div class="profile-row-main">
<a href="{{ url_for('forum_post_detail', post_id=item.post_id) }}" class="topic-title">{{ item.post_title }}</a> <a href="{{ url_for('forum_post_detail', post_id=item.post_id) }}" class="topic-title">{{ item.post_title }}</a>
<div class="topic-meta"> <div class="topic-meta">
<span class="topic-category">{{ item.post_category or '综合讨论' }}</span> <span class="topic-category">{{ item.post_category or l('综合讨论', 'General') }}</span>
<span>帖子创建:{{ item.post_created_at.strftime('%Y-%m-%d %H:%M') if item.post_created_at else '' }}</span> <span>{{ l('帖子创建:', 'Post created: ') }}{{ item.post_created_at.strftime('%Y-%m-%d %H:%M') if item.post_created_at else '' }}</span>
<span>点赞时间:{{ item.like.created_at.strftime('%Y-%m-%d %H:%M') if item.like.created_at else '' }}</span> <span>{{ l('点赞时间:', 'Liked at: ') }}{{ item.like.created_at.strftime('%Y-%m-%d %H:%M') if item.like.created_at else '' }}</span>
</div> </div>
</div> </div>
<div class="profile-row-actions"> <div class="profile-row-actions">
<form method="post" action="{{ url_for('forum_post_like_toggle', post_id=item.post_id) }}"> <form method="post" action="{{ url_for('forum_post_like_toggle', post_id=item.post_id) }}">
<input type="hidden" name="next" value="{{ url_for('user_profile', tab='likes', msg='已取消点赞') }}"> <input type="hidden" name="next" value="{{ url_for('user_profile', tab='likes', msg=l('已取消点赞', 'Like removed')) }}">
<button type="submit" class="btn-link-delete">取消点赞</button> <button type="submit" class="btn-link-delete">{{ l('取消点赞', 'Remove Like') }}</button>
</form> </form>
</div> </div>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<p class="topic-empty">你还没有点赞任何帖子。</p> <p class="topic-empty">{{ l('你还没有点赞任何帖子。', 'You have not liked any topics yet.') }}</p>
{% endif %} {% endif %}
{% elif active_tab == 'bookmarks' %} {% elif active_tab == 'bookmarks' %}
{% if my_bookmark_items %} {% if my_bookmark_items %}
@@ -145,54 +154,54 @@
<div class="profile-row-main"> <div class="profile-row-main">
<a href="{{ url_for('forum_post_detail', post_id=item.post_id) }}" class="topic-title">{{ item.post_title }}</a> <a href="{{ url_for('forum_post_detail', post_id=item.post_id) }}" class="topic-title">{{ item.post_title }}</a>
<div class="topic-meta"> <div class="topic-meta">
<span class="topic-category">{{ item.post_category or '综合讨论' }}</span> <span class="topic-category">{{ item.post_category or l('综合讨论', 'General') }}</span>
<span>帖子创建:{{ item.post_created_at.strftime('%Y-%m-%d %H:%M') if item.post_created_at else '' }}</span> <span>{{ l('帖子创建:', 'Post created: ') }}{{ item.post_created_at.strftime('%Y-%m-%d %H:%M') if item.post_created_at else '' }}</span>
<span>收藏时间:{{ item.bookmark.created_at.strftime('%Y-%m-%d %H:%M') if item.bookmark.created_at else '' }}</span> <span>{{ l('收藏时间:', 'Bookmarked at: ') }}{{ item.bookmark.created_at.strftime('%Y-%m-%d %H:%M') if item.bookmark.created_at else '' }}</span>
</div> </div>
</div> </div>
<div class="profile-row-actions"> <div class="profile-row-actions">
<form method="post" action="{{ url_for('forum_post_bookmark_toggle', post_id=item.post_id) }}"> <form method="post" action="{{ url_for('forum_post_bookmark_toggle', post_id=item.post_id) }}">
<input type="hidden" name="next" value="{{ url_for('user_profile', tab='bookmarks', msg='已取消收藏') }}"> <input type="hidden" name="next" value="{{ url_for('user_profile', tab='bookmarks', msg=l('已取消收藏', 'Bookmark removed')) }}">
<button type="submit" class="btn-link-delete">取消收藏</button> <button type="submit" class="btn-link-delete">{{ l('取消收藏', 'Remove Bookmark') }}</button>
</form> </form>
</div> </div>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<p class="topic-empty">你还没有收藏任何帖子。</p> <p class="topic-empty">{{ l('你还没有收藏任何帖子。', 'You have not bookmarked any topics yet.') }}</p>
{% endif %} {% endif %}
{% else %} {% else %}
<div class="settings-grid"> <div class="settings-grid">
<form method="post" action="{{ url_for('user_profile', tab='settings') }}" class="post-form"> <form method="post" action="{{ url_for('user_profile', tab='settings') }}" class="post-form">
<input type="hidden" name="action" value="profile"> <input type="hidden" name="action" value="profile">
<h3>基础资料</h3> <h3>{{ l('基础资料', 'Profile Info') }}</h3>
<div class="form-group"> <div class="form-group">
<label for="username">用户名</label> <label for="username">{{ l('用户名', 'Username') }}</label>
<input id="username" name="username" type="text" required minlength="3" maxlength="20" value="{{ profile_user.username }}"> <input id="username" name="username" type="text" required minlength="3" maxlength="20" value="{{ profile_user.username }}">
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="forum-btn-primary">保存资料</button> <button type="submit" class="forum-btn-primary">{{ l('保存资料', 'Save Profile') }}</button>
</div> </div>
</form> </form>
<form method="post" action="{{ url_for('user_profile', tab='settings') }}" class="post-form"> <form method="post" action="{{ url_for('user_profile', tab='settings') }}" class="post-form">
<input type="hidden" name="action" value="password"> <input type="hidden" name="action" value="password">
<h3>修改密码</h3> <h3>{{ l('修改密码', 'Change Password') }}</h3>
<div class="form-group"> <div class="form-group">
<label for="current_password">当前密码</label> <label for="current_password">{{ l('当前密码', 'Current Password') }}</label>
<input id="current_password" name="current_password" type="password" required> <input id="current_password" name="current_password" type="password" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="new_password">新密码</label> <label for="new_password">{{ l('新密码', 'New Password') }}</label>
<input id="new_password" name="new_password" type="password" required minlength="6"> <input id="new_password" name="new_password" type="password" required minlength="6">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="confirm_password">确认新密码</label> <label for="confirm_password">{{ l('确认新密码', 'Confirm New Password') }}</label>
<input id="confirm_password" name="confirm_password" type="password" required minlength="6"> <input id="confirm_password" name="confirm_password" type="password" required minlength="6">
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="forum-btn-primary">更新密码</button> <button type="submit" class="forum-btn-primary">{{ l('更新密码', 'Update Password') }}</button>
</div> </div>
</form> </form>
</div> </div>
@@ -202,19 +211,19 @@
<aside class="forum-sidebar"> <aside class="forum-sidebar">
<div class="side-card"> <div class="side-card">
<h3>账号信息</h3> <h3>{{ l('账号信息', 'Account Info') }}</h3>
<ul class="side-list"> <ul class="side-list">
<li><span>用户名</span><strong>{{ profile_user.username }}</strong></li> <li><span>{{ l('用户名', 'Username') }}</span><strong>{{ profile_user.username }}</strong></li>
<li><span>注册时间</span><strong>{{ profile_user.created_at.strftime('%Y-%m-%d') if profile_user.created_at else '—' }}</strong></li> <li><span>{{ l('注册时间', 'Joined') }}</span><strong>{{ profile_user.created_at.strftime('%Y-%m-%d') if profile_user.created_at else '—' }}</strong></li>
<li><span>最近登录</span><strong>{{ profile_user.last_login_at.strftime('%Y-%m-%d %H:%M') if profile_user.last_login_at else '—' }}</strong></li> <li><span>{{ l('最近登录', 'Last Login') }}</span><strong>{{ profile_user.last_login_at.strftime('%Y-%m-%d %H:%M') if profile_user.last_login_at else '—' }}</strong></li>
</ul> </ul>
</div> </div>
<div class="side-card"> <div class="side-card">
<h3>快捷操作</h3> <h3>{{ l('快捷操作', 'Quick Actions') }}</h3>
<div class="form-actions"> <div class="form-actions">
<a href="{{ url_for('forum_post_new') }}" class="forum-btn-primary">发布主题</a> <a href="{{ url_for('forum_post_new') }}" class="forum-btn-primary">{{ l('发布主题', 'New Topic') }}</a>
<a href="{{ url_for('user_notifications') }}" class="forum-btn-muted">查看通知</a> <a href="{{ url_for('user_notifications') }}" class="forum-btn-muted">{{ l('查看通知', 'View Notifications') }}</a>
<a href="{{ url_for('forum_index') }}" class="forum-btn-muted">返回论坛</a> <a href="{{ url_for('forum_index') }}" class="forum-btn-muted">{{ l('返回论坛', 'Back to Forum') }}</a>
</div> </div>
</div> </div>
</aside> </aside>

View File

@@ -3,7 +3,8 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ site_name }} - 阿里云/腾讯云/DigitalOcean/Vultr 等 VPS 月付价格对比</title> <title>{{ site_name }} - Global VPS Price Comparison</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='img/site-logo-mark.svg') }}">
<meta name="description" content="云服务器 VPS 价格对比表阿里云、腾讯云、华为云、DigitalOcean、Vultr、Linode、AWS Lightsail 等厂商月付价格与配置对比,支持按区域、内存筛选,一键跳转官网。"> <meta name="description" content="云服务器 VPS 价格对比表阿里云、腾讯云、华为云、DigitalOcean、Vultr、Linode、AWS Lightsail 等厂商月付价格与配置对比,支持按区域、内存筛选,一键跳转官网。">
<meta name="keywords" content="云服务器价格,VPS价格对比,阿里云价格,腾讯云价格,DigitalOcean,Vultr,Linode,云主机月付"> <meta name="keywords" content="云服务器价格,VPS价格对比,阿里云价格,腾讯云价格,DigitalOcean,Vultr,Linode,云主机月付">
<link rel="canonical" href="{{ site_url }}/"> <link rel="canonical" href="{{ site_url }}/">
@@ -42,24 +43,26 @@
<header class="header"> <header class="header">
<div class="header-inner"> <div class="header-inner">
<div class="header-brand"> <div class="header-brand">
<h1 class="logo">云价眼</h1> <a href="{{ url_for('index', lang=lang) }}" class="site-logo-link" aria-label="{{ site_name }}">
<img src="{{ url_for('static', filename='img/site-logo.svg') }}" alt="{{ site_name }} Logo" class="site-logo">
</a>
<p class="tagline">{{ t.tagline }}</p> <p class="tagline">{{ t.tagline }}</p>
</div> </div>
<nav class="header-nav"> <nav class="header-nav">
<span class="lang-switch"> <span class="lang-switch">
<a href="{{ url_for('index', lang='zh') }}" class="{{ 'active' if lang == 'zh' else '' }}">中文</a> <a href="{{ url_for('index', lang='zh') }}" class="{{ 'active' if lang == 'zh' else '' }}" title="切换到中文">中文</a>
<span class="lang-sep">|</span> <span class="lang-sep">|</span>
<a href="{{ url_for('index', lang='en') }}" class="{{ 'active' if lang == 'en' else '' }}">English</a> <a href="{{ url_for('index', lang='en') }}" class="{{ 'active' if lang == 'en' else '' }}" title="Switch to English">English</a>
</span> </span>
<a href="{{ url_for('forum_index') }}">论坛</a> <a href="{{ url_for('forum_index') }}">{{ '论坛' if lang == 'zh' else 'Forum' }}</a>
{% if current_user %} {% if current_user %}
<span class="header-user">你好{{ current_user.username }}</span> <span class="header-user">{{ ('你好' if lang == 'zh' else 'Hello') }}{{ current_user.username }}</span>
<a href="{{ url_for('user_profile') }}">个人中心</a> <a href="{{ url_for('user_profile') }}">{{ '个人中心' if lang == 'zh' else 'Profile' }}</a>
<a href="{{ url_for('user_notifications') }}" class="nav-link-with-badge">通知{% if notifications_unread_count %}<span class="nav-badge">{{ notifications_unread_count }}</span>{% endif %}</a> <a href="{{ url_for('user_notifications') }}" class="nav-link-with-badge">{{ '通知' if lang == 'zh' else 'Notifications' }}{% if notifications_unread_count %}<span class="nav-badge">{{ notifications_unread_count }}</span>{% endif %}</a>
<a href="{{ url_for('user_logout') }}">退出</a> <a href="{{ url_for('user_logout') }}">{{ '退出' if lang == 'zh' else 'Logout' }}</a>
{% else %} {% else %}
<a href="{{ url_for('user_login') }}">登录</a> <a href="{{ url_for('user_login') }}">{{ '登录' if lang == 'zh' else 'Login' }}</a>
<a href="{{ url_for('user_register') }}">注册</a> <a href="{{ url_for('user_register') }}">{{ '注册' if lang == 'zh' else 'Register' }}</a>
{% endif %} {% endif %}
</nav> </nav>
</div> </div>
@@ -171,7 +174,7 @@
</footer> </footer>
<!-- 浮动联系按钮 --> <!-- 浮动联系按钮 -->
<a href="https://t.me/dockerse" target="_blank" rel="noopener" class="floating-contact-btn" title="联系我们 - Telegram"> <a href="https://t.me/dockerse" target="_blank" rel="noopener" class="floating-contact-btn" title="{{ ('联系我们 - Telegram' if lang == 'zh' else 'Contact us - Telegram') }}">
<svg class="floating-contact-icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> <svg class="floating-contact-icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.562 8.161c-.18 1.897-.962 6.502-1.359 8.627-.168.9-.5 1.201-.82 1.23-.697.064-1.226-.461-1.901-.903-1.056-.692-1.653-1.123-2.678-1.799-1.185-.781-.417-1.21.258-1.911.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.139-5.062 3.345-.479.329-.913.489-1.302.481-.428-.009-1.252-.242-1.865-.442-.752-.244-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.831-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635.099-.002.321.023.465.141.121.099.155.232.171.326.016.094.036.308.02.475z"/> <path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.562 8.161c-.18 1.897-.962 6.502-1.359 8.627-.168.9-.5 1.201-.82 1.23-.697.064-1.226-.461-1.901-.903-1.056-.692-1.653-1.123-2.678-1.799-1.185-.781-.417-1.21.258-1.911.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.139-5.062 3.345-.479.329-.913.489-1.302.481-.428-.009-1.252-.242-1.865-.442-.752-.244-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.831-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635.099-.002.321.023.465.141.121.099.155.232.171.326.016.094.036.308.02.475z"/>
</svg> </svg>
@@ -181,7 +184,8 @@
window.LANG = {{ lang|tojson }}; window.LANG = {{ lang|tojson }};
window.I18N_JS = { window.I18N_JS = {
empty_state: {{ t.empty_state|tojson }}, 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 }}
}; };
</script> </script>
<script src="/static/js/main-simple.js"></script> <script src="/static/js/main-simple.js"></script>