This commit is contained in:
ddrwode
2026-02-10 18:33:57 +08:00
parent 4210e0d70a
commit 1f03f175f6
3 changed files with 945 additions and 15 deletions

518
app.py
View File

@@ -1,7 +1,9 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""云服务器价格对比 - Flask 应用""" """云服务器价格对比 - Flask 应用"""
import io import io
import json
import os import os
import re
from time import monotonic from time import monotonic
from datetime import datetime, timezone from datetime import datetime, timezone
from email.utils import format_datetime from email.utils import format_datetime
@@ -407,6 +409,20 @@ def _plain_excerpt(text, limit=160):
return "{}".format(raw[:max(limit - 1, 0)].rstrip()) 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): def _forum_category_description(category_name, lang):
category = (category_name or "").strip() category = (category_name or "").strip()
if not category: if not category:
@@ -814,7 +830,7 @@ def _build_forum_post_cards(rows, lang=None):
return cards 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 简洁。""" """构建论坛列表页链接,并尽量保持 URL 简洁。"""
params = {} params = {}
if (tab or "latest") != "latest": 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) size = int(per_page)
if size != 20: if size != 20:
params["per_page"] = size params["per_page"] = size
active_lang = (lang or "").strip().lower()
if active_lang == "en":
params["lang"] = "en"
return url_for("forum_index", **params) return url_for("forum_index", **params)
@@ -1049,6 +1068,322 @@ def _build_plan_trend_map(plans):
return result 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): def admin_required(f):
from functools import wraps from functools import wraps
@wraps(f) @wraps(f)
@@ -1447,6 +1782,56 @@ def api_plans():
return resp 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"]) @app.route("/register", methods=["GET", "POST"])
def user_register(): def user_register():
@@ -2225,6 +2610,10 @@ def forum_post_delete(post_id):
def forum_post_detail(post_id): def forum_post_detail(post_id):
lang = _get_lang() lang = _get_lang()
post = ForumPost.query.get_or_404(post_id) 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() current_user = _get_current_user()
viewed_posts = session.get("viewed_posts") or [] viewed_posts = session.get("viewed_posts") or []
if post.id not in viewed_posts: if post.id not in viewed_posts:
@@ -2232,13 +2621,25 @@ def forum_post_detail(post_id):
viewed_posts.append(post.id) viewed_posts.append(post.id)
session["viewed_posts"] = viewed_posts[-200:] session["viewed_posts"] = viewed_posts[-200:]
db.session.commit() db.session.commit()
comments = ( comments_query = (
ForumComment.query ForumComment.query
.options(joinedload(ForumComment.author_rel)) .options(joinedload(ForumComment.author_rel))
.filter_by(post_id=post.id) .filter_by(post_id=post.id)
.order_by(ForumComment.created_at.asc(), ForumComment.id.asc()) .order_by(ForumComment.created_at.asc(), ForumComment.id.asc())
)
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() .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() like_count = ForumPostLike.query.filter_by(post_id=post.id).count()
bookmark_count = ForumPostBookmark.query.filter_by(post_id=post.id).count() bookmark_count = ForumPostBookmark.query.filter_by(post_id=post.id).count()
liked_by_me = False liked_by_me = False
@@ -2258,6 +2659,25 @@ def forum_post_detail(post_id):
liked_by_me = "like" in kinds liked_by_me = "like" in kinds
bookmarked_by_me = "bookmark" in kinds bookmarked_by_me = "bookmark" in kinds
sidebar = _forum_sidebar_data() 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) canonical_url = _public_url("forum_post_detail", lang=lang, post_id=post.id)
post_excerpt = _plain_excerpt(post.content or "", limit=170) post_excerpt = _plain_excerpt(post.content or "", limit=170)
if not post_excerpt: if not post_excerpt:
@@ -2271,7 +2691,40 @@ def forum_post_detail(post_id):
])) ]))
published_time = _iso8601_utc(post.created_at) published_time = _iso8601_utc(post.created_at)
modified_time = _iso8601_utc(post.updated_at or 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) forum_feed_url = _public_url("forum_feed", lang=lang)
seo_title = _pick_lang( seo_title = _pick_lang(
"{} - 论坛主题 | 云价眼".format(post.title), "{} - 论坛主题 | 云价眼".format(post.title),
@@ -2283,7 +2736,13 @@ def forum_post_detail(post_id):
"description": post_excerpt, "description": post_excerpt,
"keywords": post_keywords, "keywords": post_keywords,
"canonical_url": canonical_url, "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_type": "article",
"og_url": canonical_url, "og_url": canonical_url,
"og_title": seo_title, "og_title": seo_title,
@@ -2347,7 +2806,7 @@ def forum_post_detail(post_id):
} }
comment_entities = [] 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) 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) text_excerpt = _plain_excerpt(c.content or "", limit=220)
if not text_excerpt: if not text_excerpt:
@@ -2372,10 +2831,43 @@ def forum_post_detail(post_id):
) )
breadcrumb_schema["@id"] = "{}#breadcrumb".format(canonical_url) breadcrumb_schema["@id"] = "{}#breadcrumb".format(canonical_url)
post_schema["breadcrumb"] = {"@id": breadcrumb_schema["@id"]} 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 = { seo_schema = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@graph": [post_schema, breadcrumb_schema], "@graph": seo_graph,
} }
return render_template( return render_template(
"forum/post_detail.html", "forum/post_detail.html",
@@ -2387,6 +2879,18 @@ def forum_post_detail(post_id):
bookmarked_by_me=bookmarked_by_me, bookmarked_by_me=bookmarked_by_me,
can_interact=can_interact, can_interact=can_interact,
sidebar=sidebar, 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 "", message=request.args.get("msg") or "",
error=request.args.get("error") or "", error=request.args.get("error") or "",
seo=seo, seo=seo,
@@ -2607,6 +3111,8 @@ def forum_report_create():
return _forum_redirect_with_msg(report_post_id, "举报已提交,感谢反馈") return _forum_redirect_with_msg(report_post_id, "举报已提交,感谢反馈")
@app.route("/forum/feed")
@app.route("/forum/feed.xml/")
@app.route("/forum/feed.xml") @app.route("/forum/feed.xml")
def forum_feed(): def forum_feed():
lang = _get_lang() lang = _get_lang()

View File

@@ -795,6 +795,115 @@ textarea:focus-visible {
font-size: 0.82rem; 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 { .topic-post-card {
padding: 0.96rem 1.02rem; padding: 0.96rem 1.02rem;
margin-bottom: 0.82rem; margin-bottom: 0.82rem;
@@ -825,6 +934,51 @@ textarea:focus-visible {
margin-bottom: 0.74rem; 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 { .topic-action-bar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -840,6 +994,130 @@ textarea:focus-visible {
font-size: 0.95rem; 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, .comment-form .form-group,
.post-form .form-group { .post-form .form-group {
margin-bottom: 0.76rem; margin-bottom: 0.76rem;
@@ -1300,6 +1578,10 @@ textarea:focus-visible {
.forum-search-form input[type="text"] { .forum-search-form input[type="text"] {
min-width: 180px; min-width: 180px;
} }
.topic-metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -1412,6 +1694,14 @@ textarea:focus-visible {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.topic-metric-grid {
grid-template-columns: 1fr;
}
.post-commercial-cta {
padding: 0.7rem 0.72rem;
}
.profile-stat-grid, .profile-stat-grid,
.settings-grid { .settings-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -11,6 +11,12 @@
<meta name="robots" content="{{ seo.robots }}"> <meta name="robots" content="{{ seo.robots }}">
<meta name="theme-color" content="#0F172A"> <meta name="theme-color" content="#0F172A">
<link rel="canonical" href="{{ seo.canonical_url }}"> <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() %} {% for hreflang, href in seo.alternate_links.items() %}
<link rel="alternate" hreflang="{{ hreflang }}" href="{{ href }}"> <link rel="alternate" hreflang="{{ hreflang }}" href="{{ href }}">
{% endfor %} {% endfor %}
@@ -36,8 +42,12 @@
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/forum.css"> <link rel="stylesheet" href="/static/css/forum.css">
</head> </head>
<body class="forum-page"> <body class="forum-page" 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 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"> <header class="forum-header">
<div class="forum-header-inner"> <div class="forum-header-inner">
<div class="forum-header-left"> <div class="forum-header-left">
@@ -86,8 +96,8 @@
</nav> </nav>
<section class="forum-layout"> <section class="forum-layout">
<div class="topic-stream"> <div class="topic-stream">
<article class="topic-post-card"> <article class="topic-post-card topic-detail-card">
<div class="topic-post-head"> <header class="topic-post-head">
<span class="topic-category">{{ post.category or l('综合讨论', 'General') }}</span> <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_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_featured %}<span class="topic-flag flag-featured">{{ l('精华', 'Featured') }}</span>{% endif %}
@@ -95,12 +105,17 @@
{% if post.created_at %} {% 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> <time datetime="{{ post.created_at.strftime('%Y-%m-%dT%H:%M:%SZ') }}">{{ post.created_at.strftime('%Y-%m-%d %H:%M') }}</time>
{% endif %} {% endif %}
<span>{{ l('浏览', 'Views') }} {{ post.view_count or 0 }}</span> </header>
<span>{{ l('点赞', 'Likes') }} {{ like_count or 0 }}</span>
<span>{{ l('收藏', 'Bookmarks') }} {{ bookmark_count or 0 }}</span>
</div>
<h1>{{ post.title }}</h1> <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-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"> <div class="topic-action-bar">
{% if current_user and can_interact %} {% if current_user and can_interact %}
<form method="post" action="{{ url_for('forum_post_like_toggle', post_id=post.id) }}"> <form method="post" action="{{ url_for('forum_post_like_toggle', post_id=post.id) }}">
@@ -134,11 +149,53 @@
</form> </form>
{% endif %} {% endif %}
</div> </div>
<div class="topic-post-content md-content">{{ post.content|markdown_html }}</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> </article>
<section class="topic-post-card"> <section id="comments-panel" class="topic-post-card">
<h2>{{ l('评论', 'Comments') }}{{ comments|length }}</h2> <h2>{{ l('评论', 'Comments') }}{{ comments_count or comments|length }}</h2>
{% if message %} {% if message %}
<p class="form-success">{{ message }}</p> <p class="form-success">{{ message }}</p>
{% endif %} {% endif %}
@@ -204,10 +261,44 @@
{% else %} {% else %}
<p class="topic-empty">{{ l('还没有评论,欢迎抢沙发。', 'No comments yet. Be the first to reply.') }}</p> <p class="topic-empty">{{ l('还没有评论,欢迎抢沙发。', 'No comments yet. Be the first to reply.') }}</p>
{% endif %} {% 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> </section>
</div> </div>
<aside class="forum-sidebar"> <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"> <div class="side-card">
<h3>{{ l('社区统计', 'Community Stats') }}</h3> <h3>{{ l('社区统计', 'Community Stats') }}</h3>
<div class="side-stats"> <div class="side-stats">
@@ -228,8 +319,51 @@
<p class="side-empty">{{ l('暂无分类数据', 'No category data') }}</p> <p class="side-empty">{{ l('暂无分类数据', 'No category data') }}</p>
{% endif %} {% endif %}
</div> </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> </aside>
</section> </section>
</main> </main>
<script src="{{ url_for('static', filename='js/forum-post-detail.js') }}" defer></script>
</body> </body>
</html> </html>