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

{{ post.title }}

{{ l('作者:', 'Author: ') }}{{ post.author_rel.username if post.author_rel else l('已注销用户', 'Deleted user') }}
+ +
+
{{ l('浏览', 'Views') }}{{ post.view_count or 0 }}
+
{{ l('评论', 'Comments') }}{{ comments_count or comments|length }}
+
{{ l('点赞', 'Likes') }}{{ like_count or 0 }}
+
{{ l('预计阅读', 'Read Time') }}{{ read_minutes }} {{ l('分钟', 'min') }}
+
+
{% if current_user and can_interact %}
@@ -134,11 +149,53 @@
{% endif %}
+
{{ post.content|markdown_html }}
+ {% if resource_links %} +
+

{{ l('延伸阅读与采购路径', 'Extended Reading and Buying Paths') }}

+ +
+ {% endif %} + + {% if post_faq %} +
+

{{ l('帖子导读 FAQ', 'Topic FAQ') }}

+
+ {% for item in post_faq %} +
{{ item.question }}
+
{{ item.answer }}
+ {% endfor %} +
+
+ {% endif %} + +
+
+

{{ l('准备选型或采购 VPS?', 'Ready to shortlist or buy VPS?') }}

+

{{ l('结合本帖讨论,去价格页快速筛选可落地方案。', 'Use insights from this topic and shortlist actionable plans on the pricing page.') }}

+
+
+ {{ l('去比价筛选', 'Compare Plans') }} + {% if current_user and not current_user.is_banned %} + {{ l('发布采购需求', 'Post Requirement') }} + {% else %} + {{ l('登录后发帖', 'Login to Post') }} + {% endif %} + {{ l('查看评论', 'Jump to Comments') }} +
+
-
-

{{ l('评论', 'Comments') }}({{ comments|length }})

+
+

{{ l('评论', 'Comments') }}({{ comments_count or comments|length }})

{% if message %}

{{ message }}

{% endif %} @@ -204,10 +261,44 @@ {% else %}

{{ l('还没有评论,欢迎抢沙发。', 'No comments yet. Be the first to reply.') }}

{% endif %} + + {% if comments_total_pages is defined and comments_total_pages > 1 %} + + {% endif %}
+