diff --git a/app.py b/app.py
index c98079f..ec1b4a3 100644
--- a/app.py
+++ b/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()
diff --git a/static/css/forum.css b/static/css/forum.css
index be315bc..5629e65 100644
--- a/static/css/forum.css
+++ b/static/css/forum.css
@@ -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;
diff --git a/templates/forum/post_detail.html b/templates/forum/post_detail.html
index 9ef0c7d..07856e8 100644
--- a/templates/forum/post_detail.html
+++ b/templates/forum/post_detail.html
@@ -11,6 +11,12 @@
+ {% if seo.prev_canonical_url %}
+
+ {% endif %}
+ {% if seo.next_canonical_url %}
+
+ {% endif %}
{% for hreflang, href in seo.alternate_links.items() %}
{% endfor %}
@@ -36,8 +42,12 @@
-
+
{% 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 [] %}
{{ l('评论', 'Comments') }}({{ comments_count or comments|length }})
{% if message %}{{ message }}
{% endif %} @@ -204,10 +261,44 @@ {% else %}{{ l('还没有评论,欢迎抢沙发。', 'No comments yet. Be the first to reply.') }}
{% endif %} + + {% if comments_total_pages is defined and comments_total_pages > 1 %} + + {% endif %}