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 -*-
"""云服务器价格对比 - 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()

View File

@@ -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;

View File

@@ -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>