哈哈
This commit is contained in:
518
app.py
518
app.py
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user