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