哈哈
This commit is contained in:
518
app.py
518
app.py
@@ -1,7 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""云服务器价格对比 - Flask 应用"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from time import monotonic
|
||||
from datetime import datetime, timezone
|
||||
from email.utils import format_datetime
|
||||
@@ -407,6 +409,20 @@ def _plain_excerpt(text, limit=160):
|
||||
return "{}…".format(raw[:max(limit - 1, 0)].rstrip())
|
||||
|
||||
|
||||
def _estimate_reading_minutes(text, lang="zh"):
|
||||
raw = " ".join((text or "").split())
|
||||
if not raw:
|
||||
return 1
|
||||
# 对中英混合文本做轻量估算:中文按字、英文按词处理。
|
||||
token_count = len(re.findall(r"[A-Za-z0-9_]+|[\u4e00-\u9fff]", raw))
|
||||
if lang == "en":
|
||||
rate = 220
|
||||
else:
|
||||
rate = 320
|
||||
minutes = (token_count + rate - 1) // rate
|
||||
return max(1, int(minutes))
|
||||
|
||||
|
||||
def _forum_category_description(category_name, lang):
|
||||
category = (category_name or "").strip()
|
||||
if not category:
|
||||
@@ -814,7 +830,7 @@ def _build_forum_post_cards(rows, lang=None):
|
||||
return cards
|
||||
|
||||
|
||||
def _build_forum_url(tab="latest", category=None, q=None, page=1, per_page=20):
|
||||
def _build_forum_url(tab="latest", category=None, q=None, page=1, per_page=20, lang=None):
|
||||
"""构建论坛列表页链接,并尽量保持 URL 简洁。"""
|
||||
params = {}
|
||||
if (tab or "latest") != "latest":
|
||||
@@ -829,6 +845,9 @@ def _build_forum_url(tab="latest", category=None, q=None, page=1, per_page=20):
|
||||
size = int(per_page)
|
||||
if size != 20:
|
||||
params["per_page"] = size
|
||||
active_lang = (lang or "").strip().lower()
|
||||
if active_lang == "en":
|
||||
params["lang"] = "en"
|
||||
return url_for("forum_index", **params)
|
||||
|
||||
|
||||
@@ -1049,6 +1068,322 @@ def _build_plan_trend_map(plans):
|
||||
return result
|
||||
|
||||
|
||||
def _build_post_plan_recommendations(post, lang="zh", limit=5):
|
||||
if not post:
|
||||
return [], _pick_lang("暂无推荐方案", "No recommended plans yet.", lang)
|
||||
|
||||
raw_text = "{}\n{}".format(post.title or "", post.content or "")
|
||||
text_lower = raw_text.lower()
|
||||
matched_provider_ids = []
|
||||
matched_provider_names = []
|
||||
provider_rows = Provider.query.order_by(Provider.id.asc()).limit(200).all()
|
||||
for provider in provider_rows:
|
||||
name = (provider.name or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
if name.lower() in text_lower:
|
||||
matched_provider_ids.append(provider.id)
|
||||
matched_provider_names.append(name)
|
||||
|
||||
matched_regions = []
|
||||
for region in COUNTRY_TAGS:
|
||||
item = (region or "").strip()
|
||||
if item and item in raw_text:
|
||||
matched_regions.append(item)
|
||||
if len(matched_regions) >= 3:
|
||||
break
|
||||
|
||||
price_score_expr = func.coalesce(VPSPlan.price_cny, VPSPlan.price_usd * 7.2, 999999.0)
|
||||
base_query = (
|
||||
VPSPlan.query
|
||||
.options(joinedload(VPSPlan.provider_rel))
|
||||
.filter(or_(VPSPlan.price_cny.isnot(None), VPSPlan.price_usd.isnot(None)))
|
||||
)
|
||||
scoped_query = base_query
|
||||
if matched_provider_ids:
|
||||
scoped_query = scoped_query.filter(
|
||||
or_(
|
||||
VPSPlan.provider_id.in_(matched_provider_ids),
|
||||
VPSPlan.provider.in_(matched_provider_names),
|
||||
)
|
||||
)
|
||||
if matched_regions:
|
||||
region_conds = []
|
||||
for region in matched_regions:
|
||||
region_conds.append(VPSPlan.countries.ilike("%{}%".format(region)))
|
||||
region_conds.append(VPSPlan.region.ilike("%{}%".format(region)))
|
||||
scoped_query = scoped_query.filter(or_(*region_conds))
|
||||
|
||||
ordered_scoped = scoped_query.order_by(
|
||||
price_score_expr.asc(),
|
||||
VPSPlan.vcpu.desc(),
|
||||
VPSPlan.memory_gb.desc(),
|
||||
VPSPlan.id.desc(),
|
||||
)
|
||||
picked = ordered_scoped.limit(limit).all()
|
||||
seen_ids = {p.id for p in picked if p and p.id is not None}
|
||||
if len(picked) < limit:
|
||||
fallback_rows = (
|
||||
base_query
|
||||
.order_by(
|
||||
price_score_expr.asc(),
|
||||
VPSPlan.vcpu.desc(),
|
||||
VPSPlan.memory_gb.desc(),
|
||||
VPSPlan.id.desc(),
|
||||
)
|
||||
.limit(max(limit * 2, 12))
|
||||
.all()
|
||||
)
|
||||
for row in fallback_rows:
|
||||
if not row or row.id in seen_ids:
|
||||
continue
|
||||
picked.append(row)
|
||||
seen_ids.add(row.id)
|
||||
if len(picked) >= limit:
|
||||
break
|
||||
|
||||
items = []
|
||||
for plan in picked[:limit]:
|
||||
if plan.price_cny is not None:
|
||||
price_label = _format_money("CNY", plan.price_cny)
|
||||
elif plan.price_usd is not None:
|
||||
price_label = _format_money("USD", plan.price_usd)
|
||||
else:
|
||||
price_label = _pick_lang("待更新", "TBD", lang)
|
||||
items.append({
|
||||
"id": plan.id,
|
||||
"provider": plan.provider_name or plan.provider or _pick_lang("未知厂商", "Unknown Provider", lang),
|
||||
"name": plan.display_name or _pick_lang("未命名方案", "Unnamed Plan", lang),
|
||||
"region": (plan.countries or plan.region or _pick_lang("区域未标注", "Region not specified", lang)).strip(),
|
||||
"price_label": price_label,
|
||||
"official_url": (plan.official_url or (plan.provider_rel.official_url if plan.provider_rel else "") or "").strip(),
|
||||
})
|
||||
|
||||
if matched_provider_ids and matched_regions:
|
||||
context_text = _pick_lang(
|
||||
"依据帖子中的厂商与区域关键词推荐",
|
||||
"Recommended based on provider and region keywords in this topic",
|
||||
lang,
|
||||
)
|
||||
elif matched_provider_ids:
|
||||
context_text = _pick_lang(
|
||||
"依据帖子中的厂商关键词推荐",
|
||||
"Recommended based on provider keywords in this topic",
|
||||
lang,
|
||||
)
|
||||
elif matched_regions:
|
||||
context_text = _pick_lang(
|
||||
"依据帖子中的区域关键词推荐",
|
||||
"Recommended based on region keywords in this topic",
|
||||
lang,
|
||||
)
|
||||
else:
|
||||
context_text = _pick_lang(
|
||||
"按价格与规格综合排序推荐",
|
||||
"Recommended by a combined price/spec ranking",
|
||||
lang,
|
||||
)
|
||||
return items, context_text
|
||||
|
||||
|
||||
def _build_post_detail_url(post_id, lang="zh", comment_page=1):
|
||||
"""构建帖子详情页 URL(用于评论分页链接)。"""
|
||||
page_num = 1
|
||||
try:
|
||||
page_num = int(comment_page or 1)
|
||||
except Exception:
|
||||
page_num = 1
|
||||
if page_num < 1:
|
||||
page_num = 1
|
||||
params = {"post_id": post_id}
|
||||
if page_num > 1:
|
||||
params["cp"] = page_num
|
||||
if (lang or "zh").strip().lower() == "en":
|
||||
params["lang"] = "en"
|
||||
return url_for("forum_post_detail", **params)
|
||||
|
||||
|
||||
def _build_post_comment_page_links(post_id, total_pages, current_page, lang="zh"):
|
||||
total = int(total_pages or 1)
|
||||
current = int(current_page or 1)
|
||||
if total <= 1:
|
||||
return []
|
||||
candidates = {1, total}
|
||||
for n in range(current - 2, current + 3):
|
||||
if 1 <= n <= total:
|
||||
candidates.add(n)
|
||||
ordered = sorted(candidates)
|
||||
links = []
|
||||
prev = None
|
||||
for page in ordered:
|
||||
if prev is not None and page - prev > 1:
|
||||
links.append({"is_gap": True, "label": "…"})
|
||||
links.append({
|
||||
"is_gap": False,
|
||||
"page": page,
|
||||
"url": _build_post_detail_url(post_id, lang=lang, comment_page=page),
|
||||
"active": page == current,
|
||||
})
|
||||
prev = page
|
||||
return links
|
||||
|
||||
|
||||
def _build_post_resource_links(post, lang="zh"):
|
||||
if not post:
|
||||
return []
|
||||
category_name = (post.category or "").strip()
|
||||
links = []
|
||||
if category_name:
|
||||
links.append({
|
||||
"title": _pick_lang("继续看同分类主题", "More in This Category", lang),
|
||||
"description": _pick_lang("同一分类下的最新讨论与经验汇总。", "Browse latest discussions in the same category.", lang),
|
||||
"url": _build_forum_url(category=category_name, lang=lang),
|
||||
"track_label": "resource_category",
|
||||
})
|
||||
links.extend([
|
||||
{
|
||||
"title": _pick_lang("论坛热门讨论", "Hot Forum Topics", lang),
|
||||
"description": _pick_lang("优先阅读互动度高的帖子,快速获取高信号观点。", "Prioritize high-engagement threads for stronger signals.", lang),
|
||||
"url": _build_forum_url(tab="hot", lang=lang),
|
||||
"track_label": "resource_hot",
|
||||
},
|
||||
{
|
||||
"title": _pick_lang("论坛最新动态", "Latest Forum Activity", lang),
|
||||
"description": _pick_lang("追踪最新发布和最近活跃的主题。", "Track newly posted and recently active topics.", lang),
|
||||
"url": _build_forum_url(tab="latest", lang=lang),
|
||||
"track_label": "resource_latest",
|
||||
},
|
||||
{
|
||||
"title": _pick_lang("VPS 价格总览", "VPS Pricing Console", lang),
|
||||
"description": _pick_lang("按价格、地区、配置进行方案筛选。", "Filter plans by price, region, and specs.", lang),
|
||||
"url": url_for("index", lang="en") if lang == "en" else url_for("index"),
|
||||
"track_label": "resource_pricing",
|
||||
},
|
||||
{
|
||||
"title": _pick_lang("论坛 RSS 订阅", "Forum RSS Feed", lang),
|
||||
"description": _pick_lang("通过订阅持续跟进论坛更新。", "Follow forum updates through RSS subscription.", lang),
|
||||
"url": url_for("forum_feed", lang="en") if lang == "en" else url_for("forum_feed"),
|
||||
"track_label": "resource_feed",
|
||||
},
|
||||
])
|
||||
deduped = []
|
||||
seen = set()
|
||||
for item in links:
|
||||
u = item.get("url")
|
||||
if not u or u in seen:
|
||||
continue
|
||||
seen.add(u)
|
||||
deduped.append(item)
|
||||
return deduped[:6]
|
||||
|
||||
|
||||
def _build_post_faq_items(post, comments_count=0, read_minutes=1, plan_reco_context="", lang="zh"):
|
||||
if not post:
|
||||
return []
|
||||
post_excerpt = _plain_excerpt(post.content or "", limit=180) or _pick_lang(
|
||||
"本帖围绕 VPS 选型与采购决策展开讨论。",
|
||||
"This topic discusses VPS shortlisting and procurement decisions.",
|
||||
lang,
|
||||
)
|
||||
comments_val = max(int(comments_count or 0), 0)
|
||||
read_val = max(int(read_minutes or 1), 1)
|
||||
recommendation_line = (plan_reco_context or "").strip() or _pick_lang(
|
||||
"按价格与规格综合排序推荐方案。",
|
||||
"Plans are recommended by combined price and spec ranking.",
|
||||
lang,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"question": _pick_lang("这篇帖子主要讨论什么?", "What does this topic focus on?", lang),
|
||||
"answer": post_excerpt,
|
||||
},
|
||||
{
|
||||
"question": _pick_lang("我应该先看正文还是先看评论?", "Should I read content or comments first?", lang),
|
||||
"answer": _pick_lang(
|
||||
"建议先用约 {} 分钟读完正文,再结合 {} 条评论验证观点。".format(read_val, comments_val),
|
||||
"Read the main post first in about {} minutes, then validate points with {} comments.".format(read_val, comments_val),
|
||||
lang,
|
||||
),
|
||||
},
|
||||
{
|
||||
"question": _pick_lang("下一步如何落地选型?", "What is the next step for shortlisting?", lang),
|
||||
"answer": _pick_lang(
|
||||
"{} 随后进入价格页按地区、预算和配置筛选,再到厂商官网确认条款。".format(recommendation_line),
|
||||
"{} Then use the pricing page filters (region, budget, specs) and confirm terms on official provider sites.".format(recommendation_line),
|
||||
lang,
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _build_post_howto_schema(post, canonical_url, lang="zh", read_minutes=1, comments_count=0):
|
||||
if not post or not canonical_url:
|
||||
return None
|
||||
comments_val = max(int(comments_count or 0), 0)
|
||||
read_val = max(int(read_minutes or 1), 1)
|
||||
pricing_url = _public_url("index", lang=lang)
|
||||
post_new_url = _public_url("forum_post_new", lang=lang)
|
||||
steps = [
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"position": 1,
|
||||
"name": _pick_lang("阅读主题与核心需求", "Read the topic and core requirement", lang),
|
||||
"text": _pick_lang(
|
||||
"先阅读标题和正文,明确业务目标、预算和区域要求。",
|
||||
"Read title and content first to identify workload goals, budget, and region requirements.",
|
||||
lang,
|
||||
),
|
||||
"url": canonical_url,
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"position": 2,
|
||||
"name": _pick_lang("核对评论反馈", "Validate with comments", lang),
|
||||
"text": _pick_lang(
|
||||
"结合约 {} 条评论判断观点可靠性与落地风险。".format(comments_val),
|
||||
"Use around {} comments to validate reliability and delivery risks.".format(comments_val),
|
||||
lang,
|
||||
),
|
||||
"url": "{}#comments-panel".format(canonical_url),
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"position": 3,
|
||||
"name": _pick_lang("进入价格页筛选方案", "Filter plans on pricing page", lang),
|
||||
"text": _pick_lang(
|
||||
"按地区、价格和配置过滤候选 VPS,建立短名单。",
|
||||
"Filter candidates by region, price, and specs to build a shortlist.",
|
||||
lang,
|
||||
),
|
||||
"url": pricing_url,
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"position": 4,
|
||||
"name": _pick_lang("补充需求并确认采购", "Publish requirement and finalize", lang),
|
||||
"text": _pick_lang(
|
||||
"若信息仍不足,可发布新主题补充业务约束并确认采购方案。",
|
||||
"If signal is still insufficient, publish a follow-up topic and finalize the buying plan.",
|
||||
lang,
|
||||
),
|
||||
"url": post_new_url,
|
||||
},
|
||||
]
|
||||
return {
|
||||
"@type": "HowTo",
|
||||
"@id": "{}#howto".format(canonical_url),
|
||||
"name": _pick_lang("如何从论坛主题完成 VPS 选型", "How to shortlist VPS from a forum topic", lang),
|
||||
"description": _pick_lang(
|
||||
"从阅读帖子到筛选方案再到确认采购的标准流程。",
|
||||
"A practical workflow from reading a discussion to shortlisting and procurement.",
|
||||
lang,
|
||||
),
|
||||
"inLanguage": "en-US" if lang == "en" else "zh-CN",
|
||||
"totalTime": "PT{}M".format(max(3, read_val + 2)),
|
||||
"step": steps,
|
||||
}
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
from functools import wraps
|
||||
@wraps(f)
|
||||
@@ -1447,6 +1782,56 @@ def api_plans():
|
||||
return resp
|
||||
|
||||
|
||||
@app.route("/api/event/track", methods=["POST"])
|
||||
def api_event_track():
|
||||
payload = {}
|
||||
if request.is_json:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
if not payload:
|
||||
payload = request.form.to_dict(flat=True)
|
||||
event_name = (payload.get("event_name") or "").strip().lower()
|
||||
if not re.match(r"^[a-z0-9_]{3,64}$", event_name or ""):
|
||||
return ("", 204)
|
||||
|
||||
whitelist = {
|
||||
"post_detail_cta_pricing",
|
||||
"post_detail_cta_new_topic",
|
||||
"post_detail_jump_comments",
|
||||
"post_detail_related_click",
|
||||
"post_detail_plan_click",
|
||||
"post_detail_comment_submit",
|
||||
"post_detail_sidebar_compare",
|
||||
"post_detail_resource_click",
|
||||
}
|
||||
if event_name not in whitelist:
|
||||
return ("", 204)
|
||||
|
||||
label = " ".join((payload.get("label") or "").strip().split())[:120]
|
||||
page_path = " ".join((payload.get("page_path") or "").strip().split())[:255]
|
||||
post_id = payload.get("post_id")
|
||||
try:
|
||||
post_id = int(post_id) if post_id is not None else None
|
||||
except Exception:
|
||||
post_id = None
|
||||
if not page_path:
|
||||
referer = (request.headers.get("Referer") or "").strip()
|
||||
page_path = referer[:255]
|
||||
user = _get_current_user()
|
||||
event_data = {
|
||||
"event_name": event_name,
|
||||
"label": label,
|
||||
"post_id": post_id,
|
||||
"user_id": user.id if user else None,
|
||||
"page_path": page_path,
|
||||
"endpoint_path": request.path,
|
||||
"referer": (request.headers.get("Referer") or "")[:255],
|
||||
"ip": (request.headers.get("X-Forwarded-For") or request.remote_addr or "")[:120],
|
||||
"at": _iso8601_utc(datetime.now(timezone.utc)),
|
||||
}
|
||||
app.logger.info("forum_track_event %s", json.dumps(event_data, ensure_ascii=False))
|
||||
return ("", 204)
|
||||
|
||||
|
||||
# ---------- 前台用户与论坛 ----------
|
||||
@app.route("/register", methods=["GET", "POST"])
|
||||
def user_register():
|
||||
@@ -2225,6 +2610,10 @@ def forum_post_delete(post_id):
|
||||
def forum_post_detail(post_id):
|
||||
lang = _get_lang()
|
||||
post = ForumPost.query.get_or_404(post_id)
|
||||
comment_per_page = 20
|
||||
comment_page = request.args.get("cp", type=int) or 1
|
||||
if comment_page < 1:
|
||||
comment_page = 1
|
||||
current_user = _get_current_user()
|
||||
viewed_posts = session.get("viewed_posts") or []
|
||||
if post.id not in viewed_posts:
|
||||
@@ -2232,13 +2621,25 @@ def forum_post_detail(post_id):
|
||||
viewed_posts.append(post.id)
|
||||
session["viewed_posts"] = viewed_posts[-200:]
|
||||
db.session.commit()
|
||||
comments = (
|
||||
comments_query = (
|
||||
ForumComment.query
|
||||
.options(joinedload(ForumComment.author_rel))
|
||||
.filter_by(post_id=post.id)
|
||||
.order_by(ForumComment.created_at.asc(), ForumComment.id.asc())
|
||||
)
|
||||
comments_count = comments_query.count()
|
||||
comments_total_pages = max((comments_count + comment_per_page - 1) // comment_per_page, 1)
|
||||
if comment_page > comments_total_pages:
|
||||
comment_page = comments_total_pages
|
||||
comments = (
|
||||
comments_query
|
||||
.offset((comment_page - 1) * comment_per_page)
|
||||
.limit(comment_per_page)
|
||||
.all()
|
||||
)
|
||||
schema_comments = comments
|
||||
if comment_page > 1:
|
||||
schema_comments = comments_query.limit(20).all()
|
||||
like_count = ForumPostLike.query.filter_by(post_id=post.id).count()
|
||||
bookmark_count = ForumPostBookmark.query.filter_by(post_id=post.id).count()
|
||||
liked_by_me = False
|
||||
@@ -2258,6 +2659,25 @@ def forum_post_detail(post_id):
|
||||
liked_by_me = "like" in kinds
|
||||
bookmarked_by_me = "bookmark" in kinds
|
||||
sidebar = _forum_sidebar_data()
|
||||
related_rows = (
|
||||
_query_forum_post_rows(active_tab="latest", selected_category=post.category or None)
|
||||
.filter(ForumPost.id != post.id)
|
||||
.limit(6)
|
||||
.all()
|
||||
)
|
||||
if not related_rows:
|
||||
related_rows = (
|
||||
_query_forum_post_rows(active_tab="hot")
|
||||
.filter(ForumPost.id != post.id)
|
||||
.limit(6)
|
||||
.all()
|
||||
)
|
||||
related_cards = _build_forum_post_cards(related_rows, lang=lang)
|
||||
plan_recommendations, plan_reco_context = _build_post_plan_recommendations(
|
||||
post=post,
|
||||
lang=lang,
|
||||
limit=5,
|
||||
)
|
||||
canonical_url = _public_url("forum_post_detail", lang=lang, post_id=post.id)
|
||||
post_excerpt = _plain_excerpt(post.content or "", limit=170)
|
||||
if not post_excerpt:
|
||||
@@ -2271,7 +2691,40 @@ def forum_post_detail(post_id):
|
||||
]))
|
||||
published_time = _iso8601_utc(post.created_at)
|
||||
modified_time = _iso8601_utc(post.updated_at or post.created_at)
|
||||
comments_count = len(comments)
|
||||
read_minutes = _estimate_reading_minutes(post.content or "", lang=lang)
|
||||
detail_resource_links = _build_post_resource_links(post=post, lang=lang)
|
||||
detail_faq_items = _build_post_faq_items(
|
||||
post=post,
|
||||
comments_count=comments_count,
|
||||
read_minutes=read_minutes,
|
||||
plan_reco_context=plan_reco_context,
|
||||
lang=lang,
|
||||
)
|
||||
comment_page_links = _build_post_comment_page_links(
|
||||
post_id=post.id,
|
||||
total_pages=comments_total_pages,
|
||||
current_page=comment_page,
|
||||
lang=lang,
|
||||
)
|
||||
comment_prev_url = None
|
||||
comment_next_url = None
|
||||
comment_prev_canonical_url = None
|
||||
comment_next_canonical_url = None
|
||||
if comment_page > 1:
|
||||
comment_prev_url = _build_post_detail_url(post.id, lang=lang, comment_page=comment_page - 1)
|
||||
prev_cp = (comment_page - 1) if (comment_page - 1) > 1 else None
|
||||
comment_prev_canonical_url = _public_url("forum_post_detail", lang=lang, post_id=post.id, cp=prev_cp)
|
||||
if comment_page < comments_total_pages:
|
||||
comment_next_url = _build_post_detail_url(post.id, lang=lang, comment_page=comment_page + 1)
|
||||
comment_next_canonical_url = _public_url("forum_post_detail", lang=lang, post_id=post.id, cp=comment_page + 1)
|
||||
|
||||
query_keys = {str(k or "").strip().lower() for k in request.args.keys()}
|
||||
query_keys.discard("")
|
||||
indexable_query_keys = {"lang"}
|
||||
has_non_canonical_query = any(
|
||||
(key not in indexable_query_keys) or key.startswith("utm_")
|
||||
for key in query_keys
|
||||
)
|
||||
forum_feed_url = _public_url("forum_feed", lang=lang)
|
||||
seo_title = _pick_lang(
|
||||
"{} - 论坛主题 | 云价眼".format(post.title),
|
||||
@@ -2283,7 +2736,13 @@ def forum_post_detail(post_id):
|
||||
"description": post_excerpt,
|
||||
"keywords": post_keywords,
|
||||
"canonical_url": canonical_url,
|
||||
"robots": "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1",
|
||||
"robots": (
|
||||
"noindex,follow"
|
||||
if has_non_canonical_query
|
||||
else "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1"
|
||||
),
|
||||
"prev_canonical_url": comment_prev_canonical_url,
|
||||
"next_canonical_url": comment_next_canonical_url,
|
||||
"og_type": "article",
|
||||
"og_url": canonical_url,
|
||||
"og_title": seo_title,
|
||||
@@ -2347,7 +2806,7 @@ def forum_post_detail(post_id):
|
||||
}
|
||||
|
||||
comment_entities = []
|
||||
for c in comments[:20]:
|
||||
for c in schema_comments[:20]:
|
||||
author = c.author_rel.username if c.author_rel and c.author_rel.username else _pick_lang("匿名用户", "Anonymous", lang)
|
||||
text_excerpt = _plain_excerpt(c.content or "", limit=220)
|
||||
if not text_excerpt:
|
||||
@@ -2372,10 +2831,43 @@ def forum_post_detail(post_id):
|
||||
)
|
||||
breadcrumb_schema["@id"] = "{}#breadcrumb".format(canonical_url)
|
||||
post_schema["breadcrumb"] = {"@id": breadcrumb_schema["@id"]}
|
||||
faq_schema = None
|
||||
if detail_faq_items:
|
||||
faq_schema = {
|
||||
"@type": "FAQPage",
|
||||
"@id": "{}#faq".format(canonical_url),
|
||||
"inLanguage": "en-US" if lang == "en" else "zh-CN",
|
||||
"mainEntity": [
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": item.get("question"),
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": item.get("answer"),
|
||||
},
|
||||
}
|
||||
for item in detail_faq_items
|
||||
if item.get("question") and item.get("answer")
|
||||
],
|
||||
}
|
||||
if not faq_schema["mainEntity"]:
|
||||
faq_schema = None
|
||||
howto_schema = _build_post_howto_schema(
|
||||
post=post,
|
||||
canonical_url=canonical_url,
|
||||
lang=lang,
|
||||
read_minutes=read_minutes,
|
||||
comments_count=comments_count,
|
||||
)
|
||||
seo_graph = [post_schema, breadcrumb_schema]
|
||||
if faq_schema:
|
||||
seo_graph.append(faq_schema)
|
||||
if howto_schema:
|
||||
seo_graph.append(howto_schema)
|
||||
|
||||
seo_schema = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [post_schema, breadcrumb_schema],
|
||||
"@graph": seo_graph,
|
||||
}
|
||||
return render_template(
|
||||
"forum/post_detail.html",
|
||||
@@ -2387,6 +2879,18 @@ def forum_post_detail(post_id):
|
||||
bookmarked_by_me=bookmarked_by_me,
|
||||
can_interact=can_interact,
|
||||
sidebar=sidebar,
|
||||
related_cards=related_cards,
|
||||
plan_recommendations=plan_recommendations,
|
||||
plan_reco_context=plan_reco_context,
|
||||
detail_resource_links=detail_resource_links,
|
||||
detail_faq_items=detail_faq_items,
|
||||
comments_count=comments_count,
|
||||
read_minutes=read_minutes,
|
||||
comment_page=comment_page,
|
||||
comments_total_pages=comments_total_pages,
|
||||
comment_page_links=comment_page_links,
|
||||
comment_prev_url=comment_prev_url,
|
||||
comment_next_url=comment_next_url,
|
||||
message=request.args.get("msg") or "",
|
||||
error=request.args.get("error") or "",
|
||||
seo=seo,
|
||||
@@ -2607,6 +3111,8 @@ def forum_report_create():
|
||||
return _forum_redirect_with_msg(report_post_id, "举报已提交,感谢反馈")
|
||||
|
||||
|
||||
@app.route("/forum/feed")
|
||||
@app.route("/forum/feed.xml/")
|
||||
@app.route("/forum/feed.xml")
|
||||
def forum_feed():
|
||||
lang = _get_lang()
|
||||
|
||||
@@ -795,6 +795,115 @@ textarea:focus-visible {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.side-cta p {
|
||||
margin: 0 0 0.7rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.related-post-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.related-post-list li {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem 0.56rem;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.related-post-list a {
|
||||
display: block;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 0.83rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.related-post-list a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.related-post-list a:focus-visible {
|
||||
outline: 2px solid rgba(3, 105, 161, 0.45);
|
||||
outline-offset: 2px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.related-post-meta {
|
||||
margin-top: 0.3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.plan-reco-context {
|
||||
margin: 0 0 0.62rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.plan-reco-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.plan-reco-list li {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem 0.56rem;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.plan-reco-list a {
|
||||
display: block;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 0.83rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.plan-reco-list a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.plan-reco-list a:focus-visible {
|
||||
outline: 2px solid rgba(3, 105, 161, 0.45);
|
||||
outline-offset: 2px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.plan-reco-meta {
|
||||
margin-top: 0.3rem;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.55rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.plan-reco-meta strong {
|
||||
color: #0f172a;
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.topic-post-card {
|
||||
padding: 0.96rem 1.02rem;
|
||||
margin-bottom: 0.82rem;
|
||||
@@ -825,6 +934,51 @@ textarea:focus-visible {
|
||||
margin-bottom: 0.74rem;
|
||||
}
|
||||
|
||||
.topic-detail-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topic-detail-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #0f172a, #0369a1, #b45309);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.topic-metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.55rem;
|
||||
margin: 0 0 0.72rem;
|
||||
}
|
||||
|
||||
.topic-metric-grid > div {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem 0.56rem;
|
||||
background: linear-gradient(180deg, var(--bg-elevated), rgba(248, 250, 252, 0.7));
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.topic-metric-grid span {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.topic-metric-grid strong {
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.topic-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -840,6 +994,130 @@ textarea:focus-visible {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.post-commercial-cta {
|
||||
margin-top: 0.9rem;
|
||||
border: 1px solid rgba(3, 105, 161, 0.26);
|
||||
border-radius: 12px;
|
||||
padding: 0.76rem 0.82rem;
|
||||
background: linear-gradient(145deg, rgba(3, 105, 161, 0.08), rgba(15, 23, 42, 0.03));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.85rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.post-commercial-copy h3 {
|
||||
margin: 0 0 0.22rem;
|
||||
font-size: 0.95rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.post-commercial-copy p {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.post-commercial-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.post-resource-links {
|
||||
margin-top: 0.9rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.34);
|
||||
border-radius: 12px;
|
||||
padding: 0.72rem 0.78rem;
|
||||
background: rgba(248, 250, 252, 0.72);
|
||||
}
|
||||
|
||||
.post-resource-links h2 {
|
||||
margin: 0 0 0.55rem;
|
||||
font-size: 0.93rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.resource-link-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.52rem;
|
||||
}
|
||||
|
||||
.resource-link-list li {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem 0.56rem;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.resource-link-list a {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.resource-link-list a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.resource-link-list a:focus-visible {
|
||||
outline: 2px solid rgba(3, 105, 161, 0.45);
|
||||
outline-offset: 2px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.resource-link-list p {
|
||||
margin: 0.28rem 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.post-faq-panel {
|
||||
margin-top: 0.84rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.34);
|
||||
border-radius: 12px;
|
||||
padding: 0.72rem 0.78rem;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.post-faq-panel h2 {
|
||||
margin: 0 0 0.55rem;
|
||||
font-size: 0.93rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.post-faq-panel dl {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.post-faq-panel dt {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.post-faq-panel dd {
|
||||
margin: 0.3rem 0 0.6rem;
|
||||
color: #475569;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.post-faq-panel dd:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.comment-form .form-group,
|
||||
.post-form .form-group {
|
||||
margin-bottom: 0.76rem;
|
||||
@@ -1300,6 +1578,10 @@ textarea:focus-visible {
|
||||
.forum-search-form input[type="text"] {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.topic-metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -1412,6 +1694,14 @@ textarea:focus-visible {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topic-metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.post-commercial-cta {
|
||||
padding: 0.7rem 0.72rem;
|
||||
}
|
||||
|
||||
.profile-stat-grid,
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
<meta name="robots" content="{{ seo.robots }}">
|
||||
<meta name="theme-color" content="#0F172A">
|
||||
<link rel="canonical" href="{{ seo.canonical_url }}">
|
||||
{% if seo.prev_canonical_url %}
|
||||
<link rel="prev" href="{{ seo.prev_canonical_url }}">
|
||||
{% endif %}
|
||||
{% if seo.next_canonical_url %}
|
||||
<link rel="next" href="{{ seo.next_canonical_url }}">
|
||||
{% endif %}
|
||||
{% for hreflang, href in seo.alternate_links.items() %}
|
||||
<link rel="alternate" hreflang="{{ hreflang }}" href="{{ href }}">
|
||||
{% endfor %}
|
||||
@@ -36,8 +42,12 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/forum.css">
|
||||
</head>
|
||||
<body class="forum-page">
|
||||
<body class="forum-page" data-post-id="{{ post.id }}">
|
||||
{% set sb = sidebar if sidebar is defined else {'total_users': 0, 'total_posts': 0, 'total_comments': 0, 'category_counts': []} %}
|
||||
{% set related = related_cards if related_cards is defined else [] %}
|
||||
{% set plan_recos = plan_recommendations if plan_recommendations is defined else [] %}
|
||||
{% set resource_links = detail_resource_links if detail_resource_links is defined else [] %}
|
||||
{% set post_faq = detail_faq_items if detail_faq_items is defined else [] %}
|
||||
<header class="forum-header">
|
||||
<div class="forum-header-inner">
|
||||
<div class="forum-header-left">
|
||||
@@ -86,8 +96,8 @@
|
||||
</nav>
|
||||
<section class="forum-layout">
|
||||
<div class="topic-stream">
|
||||
<article class="topic-post-card">
|
||||
<div class="topic-post-head">
|
||||
<article class="topic-post-card topic-detail-card">
|
||||
<header class="topic-post-head">
|
||||
<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 %}
|
||||
@@ -95,12 +105,17 @@
|
||||
{% if post.created_at %}
|
||||
<time datetime="{{ post.created_at.strftime('%Y-%m-%dT%H:%M:%SZ') }}">{{ post.created_at.strftime('%Y-%m-%d %H:%M') }}</time>
|
||||
{% endif %}
|
||||
<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>
|
||||
</header>
|
||||
<h1>{{ post.title }}</h1>
|
||||
<div class="topic-post-author">{{ l('作者:', 'Author: ') }}{{ post.author_rel.username if post.author_rel else l('已注销用户', 'Deleted user') }}</div>
|
||||
|
||||
<div class="topic-metric-grid">
|
||||
<div><span>{{ l('浏览', 'Views') }}</span><strong>{{ post.view_count or 0 }}</strong></div>
|
||||
<div><span>{{ l('评论', 'Comments') }}</span><strong>{{ comments_count or comments|length }}</strong></div>
|
||||
<div><span>{{ l('点赞', 'Likes') }}</span><strong>{{ like_count or 0 }}</strong></div>
|
||||
<div><span>{{ l('预计阅读', 'Read Time') }}</span><strong>{{ read_minutes }} {{ l('分钟', 'min') }}</strong></div>
|
||||
</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) }}">
|
||||
@@ -134,11 +149,53 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="topic-post-content md-content">{{ post.content|markdown_html }}</div>
|
||||
{% if resource_links %}
|
||||
<section class="post-resource-links" aria-label="{{ l('延伸阅读与采购路径', 'Extended Reading and Buying Paths') }}">
|
||||
<h2>{{ l('延伸阅读与采购路径', 'Extended Reading and Buying Paths') }}</h2>
|
||||
<ul class="resource-link-list">
|
||||
{% for item in resource_links %}
|
||||
<li>
|
||||
<a href="{{ item.url }}" data-track-event="post_detail_resource_click" data-track-label="{{ item.track_label }}">{{ item.title }}</a>
|
||||
<p>{{ item.description }}</p>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if post_faq %}
|
||||
<section class="post-faq-panel" aria-label="{{ l('帖子导读 FAQ', 'Topic FAQ') }}">
|
||||
<h2>{{ l('帖子导读 FAQ', 'Topic FAQ') }}</h2>
|
||||
<dl>
|
||||
{% for item in post_faq %}
|
||||
<dt>{{ item.question }}</dt>
|
||||
<dd>{{ item.answer }}</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="post-commercial-cta">
|
||||
<div class="post-commercial-copy">
|
||||
<h3>{{ l('准备选型或采购 VPS?', 'Ready to shortlist or buy VPS?') }}</h3>
|
||||
<p>{{ l('结合本帖讨论,去价格页快速筛选可落地方案。', 'Use insights from this topic and shortlist actionable plans on the pricing page.') }}</p>
|
||||
</div>
|
||||
<div class="post-commercial-actions">
|
||||
<a href="{{ url_for('index') }}" class="forum-btn-primary" data-track-event="post_detail_cta_pricing" data-track-label="main_compare_plans">{{ l('去比价筛选', 'Compare Plans') }}</a>
|
||||
{% if current_user and not current_user.is_banned %}
|
||||
<a href="{{ url_for('forum_post_new') }}" class="forum-btn-muted" data-track-event="post_detail_cta_new_topic" data-track-label="main_post_requirement">{{ l('发布采购需求', 'Post Requirement') }}</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('user_login', next='/forum/post/new') }}" class="forum-btn-muted" data-track-event="post_detail_cta_new_topic" data-track-label="main_login_post_requirement">{{ l('登录后发帖', 'Login to Post') }}</a>
|
||||
{% endif %}
|
||||
<a href="#comments-panel" class="forum-btn-muted" data-track-event="post_detail_jump_comments" data-track-label="main_jump_comments">{{ l('查看评论', 'Jump to Comments') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<section class="topic-post-card">
|
||||
<h2>{{ l('评论', 'Comments') }}({{ comments|length }})</h2>
|
||||
<section id="comments-panel" class="topic-post-card">
|
||||
<h2>{{ l('评论', 'Comments') }}({{ comments_count or comments|length }})</h2>
|
||||
{% if message %}
|
||||
<p class="form-success">{{ message }}</p>
|
||||
{% endif %}
|
||||
@@ -204,10 +261,44 @@
|
||||
{% else %}
|
||||
<p class="topic-empty">{{ l('还没有评论,欢迎抢沙发。', 'No comments yet. Be the first to reply.') }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if comments_total_pages is defined and comments_total_pages > 1 %}
|
||||
<nav class="forum-pagination forum-pagination-inline" aria-label="{{ l('评论分页', 'Comment Pagination') }}">
|
||||
{% if comment_prev_url %}
|
||||
<a class="page-link" href="{{ comment_prev_url }}">{{ l('上一页', 'Prev') }}</a>
|
||||
{% else %}
|
||||
<span class="page-link disabled">{{ l('上一页', 'Prev') }}</span>
|
||||
{% endif %}
|
||||
{% for item in comment_page_links %}
|
||||
{% if item.is_gap %}
|
||||
<span class="page-link disabled">{{ item.label }}</span>
|
||||
{% else %}
|
||||
<a class="page-link {{ 'active' if item.active else '' }}" href="{{ item.url }}">{{ item.page }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if comment_next_url %}
|
||||
<a class="page-link" href="{{ comment_next_url }}">{{ l('下一页', 'Next') }}</a>
|
||||
{% else %}
|
||||
<span class="page-link disabled">{{ l('下一页', 'Next') }}</span>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="forum-sidebar">
|
||||
<div class="side-card side-cta">
|
||||
<h3>{{ l('商业化入口', 'Commercial Actions') }}</h3>
|
||||
<p>{{ l('你可以基于本帖结论直接筛选方案,或发布更具体的业务需求。', 'Shortlist plans directly from this topic, or publish a more specific workload requirement.') }}</p>
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('index') }}" class="forum-btn-primary" data-track-event="post_detail_sidebar_compare" data-track-label="sidebar_shortlist">{{ l('立即筛选 VPS', 'Shortlist VPS') }}</a>
|
||||
{% if current_user and not current_user.is_banned %}
|
||||
<a href="{{ url_for('forum_post_new') }}" class="forum-btn-muted" data-track-event="post_detail_cta_new_topic" data-track-label="sidebar_new_topic">{{ l('发布新主题', 'New Topic') }}</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('user_login', next='/forum/post/new') }}" class="forum-btn-muted" data-track-event="post_detail_cta_new_topic" data-track-label="sidebar_login_new_topic">{{ l('登录后发帖', 'Login to Post') }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="side-card">
|
||||
<h3>{{ l('社区统计', 'Community Stats') }}</h3>
|
||||
<div class="side-stats">
|
||||
@@ -228,8 +319,51 @@
|
||||
<p class="side-empty">{{ l('暂无分类数据', 'No category data') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="side-card">
|
||||
<h3>{{ l('相关推荐', 'Related Topics') }}</h3>
|
||||
{% if related %}
|
||||
<ul class="related-post-list">
|
||||
{% for item in related %}
|
||||
{% set rp = item.post %}
|
||||
<li>
|
||||
<a href="{{ url_for('forum_post_detail', post_id=rp.id) }}" data-track-event="post_detail_related_click" data-track-label="{{ rp.title }}">{{ rp.title }}</a>
|
||||
<div class="related-post-meta">
|
||||
<span>{{ l('回复', 'Replies') }} {{ item.reply_count }}</span>
|
||||
<span>{{ l('浏览', 'Views') }} {{ item.view_count }}</span>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="side-empty">{{ l('暂无相关推荐', 'No related topics yet') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="side-card">
|
||||
<h3>{{ l('推荐方案', 'Recommended Plans') }}</h3>
|
||||
<p class="plan-reco-context">{{ plan_reco_context if plan_reco_context is defined else l('按价格与规格综合排序推荐', 'Recommended by a combined price/spec ranking') }}</p>
|
||||
{% if plan_recos %}
|
||||
<ul class="plan-reco-list">
|
||||
{% for plan in plan_recos %}
|
||||
<li>
|
||||
{% set plan_href = plan.official_url or url_for('index') %}
|
||||
<a href="{{ plan_href }}" {% if plan.official_url %}target="_blank" rel="noopener nofollow"{% endif %} data-track-event="post_detail_plan_click" data-track-label="{{ plan.provider ~ ' ' ~ plan.name }}">{{ plan.provider }} · {{ plan.name }}</a>
|
||||
<div class="plan-reco-meta">
|
||||
<span>{{ plan.region }}</span>
|
||||
<strong>{{ plan.price_label }}</strong>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="side-empty">{{ l('暂无可推荐方案', 'No plan recommendations yet') }}</p>
|
||||
{% endif %}
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('index') }}" class="forum-btn-muted" data-track-event="post_detail_sidebar_compare" data-track-label="sidebar_view_all_plans">{{ l('查看全部方案', 'View All Plans') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
<script src="{{ url_for('static', filename='js/forum-post-detail.js') }}" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user