diff --git a/app.py b/app.py index 1d67924..c98079f 100644 --- a/app.py +++ b/app.py @@ -4,8 +4,22 @@ import io import os from time import monotonic from datetime import datetime, timezone +from email.utils import format_datetime from urllib.parse import urlencode -from flask import Flask, render_template, jsonify, request, redirect, url_for, session, send_file, send_from_directory +from xml.sax.saxutils import escape as xml_escape +from flask import ( + Flask, + abort, + jsonify, + make_response, + redirect, + render_template, + request, + send_file, + send_from_directory, + session, + url_for, +) from werkzeug.middleware.proxy_fix import ProxyFix from sqlalchemy import text, func, or_ from sqlalchemy.orm import joinedload @@ -272,6 +286,32 @@ FORUM_NOTIFICATION_TYPE_LABELS_EN = { "content_removed": "Content moderation", } +# Sitemap 单个文件最大帖子条数(按语言拆分后可稳定低于 50k URL 上限) +SITEMAP_POSTS_PER_FILE = 25000 + +FORUM_CATEGORY_SEO_COPY = { + "综合讨论": { + "zh": "围绕 VPS 选型、采购和实践经验的综合讨论区。", + "en": "General discussions about VPS planning, buying, and operations.", + }, + "VPS 评测": { + "zh": "集中查看 VPS 评测、性能体验与线路反馈。", + "en": "Hands-on VPS reviews, benchmarks, and network feedback.", + }, + "优惠活动": { + "zh": "跟踪厂商促销、折扣活动与限时优惠。", + "en": "Track provider promotions, discounts, and limited-time deals.", + }, + "运维经验": { + "zh": "分享部署、监控、故障排查与稳定性实践。", + "en": "Operations playbooks for deployment, monitoring, and troubleshooting.", + }, + "新手提问": { + "zh": "面向新手的配置建议与入门答疑。", + "en": "Beginner-friendly Q&A for VPS setup and decision making.", + }, +} + def _get_lang(): lang = ( @@ -309,6 +349,239 @@ def _lang_url(lang_code): return "{}?{}".format(request.path, urlencode(params)) +def _site_root_url(): + return (SITE_URL or "").rstrip("/") + + +def _absolute_url_for(endpoint, **values): + return "{}{}".format(_site_root_url(), url_for(endpoint, **values)) + + +def _public_url(endpoint, lang="zh", **params): + values = {} + for key, value in params.items(): + if value is None: + continue + if isinstance(value, str) and not value.strip(): + continue + values[key] = value + if (lang or "zh").strip().lower() == "en": + values["lang"] = "en" + else: + values.pop("lang", None) + return _absolute_url_for(endpoint, **values) + + +def _alternate_lang_links(endpoint, **params): + return { + "zh-CN": _public_url(endpoint, lang="zh", **params), + "en-US": _public_url(endpoint, lang="en", **params), + "x-default": _public_url(endpoint, lang="zh", **params), + } + + +def _iso8601_utc(dt): + if not dt: + return None + if dt.tzinfo is None: + aware = dt.replace(tzinfo=timezone.utc) + else: + aware = dt.astimezone(timezone.utc) + return aware.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _rfc2822_utc(dt): + if not dt: + return None + if dt.tzinfo is None: + aware = dt.replace(tzinfo=timezone.utc) + else: + aware = dt.astimezone(timezone.utc) + return format_datetime(aware, usegmt=True) + + +def _plain_excerpt(text, limit=160): + raw = " ".join((text or "").split()) + if len(raw) <= limit: + return raw + return "{}…".format(raw[:max(limit - 1, 0)].rstrip()) + + +def _forum_category_description(category_name, lang): + category = (category_name or "").strip() + if not category: + return _pick_lang( + "聚合 VPS 评测、运维经验与采购讨论,帮助团队完成云资源选型。", + "A VPS community for reviews, operations knowledge, and procurement discussions.", + lang, + ) + preset = FORUM_CATEGORY_SEO_COPY.get(category) or {} + if lang == "en": + return preset.get("en") or "Community topics tagged '{}' for VPS reviews, operations, and buying decisions.".format(category) + return preset.get("zh") or "浏览“{}”分类下的 VPS 讨论、评测与采购经验。".format(category) + + +def _forum_index_keywords(lang, active_tab="latest", selected_category=None): + if lang == "en": + keywords = [ + "VPS forum", + "VPS community", + "cloud server reviews", + "VPS buying guide", + "VPS operations", + ] + tab_map = { + "latest": "latest VPS topics", + "new": "new VPS posts", + "hot": "popular VPS discussions", + } + else: + keywords = [ + "VPS论坛", + "VPS社区", + "云服务器评测", + "VPS采购建议", + "VPS运维经验", + ] + tab_map = { + "latest": "最新帖子", + "new": "新帖", + "hot": "热门讨论", + } + tab_keyword = tab_map.get(active_tab) + if tab_keyword: + keywords.append(tab_keyword) + if selected_category: + keywords.append(selected_category) + return ", ".join(dict.fromkeys(keywords)) + + +def _forum_breadcrumb_schema(lang, selected_category=None, post=None, post_url=None): + items = [ + { + "@type": "ListItem", + "position": 1, + "name": _pick_lang("首页", "Home", lang), + "item": _public_url("index", lang=lang), + }, + { + "@type": "ListItem", + "position": 2, + "name": _pick_lang("论坛", "Forum", lang), + "item": _public_url("forum_index", lang=lang), + }, + ] + if selected_category: + items.append({ + "@type": "ListItem", + "position": len(items) + 1, + "name": selected_category, + "item": _public_url("forum_index", lang=lang, category=selected_category), + }) + if post and post_url: + items.append({ + "@type": "ListItem", + "position": len(items) + 1, + "name": post.title, + "item": post_url, + }) + return { + "@type": "BreadcrumbList", + "itemListElement": items, + } + + +def _sitemap_alternates(endpoint, **params): + links = _alternate_lang_links(endpoint, **params) + return [{"hreflang": k, "href": v} for k, v in links.items()] + + +def _build_sitemap_urlset_xml(url_items): + lines = [ + '', + '', + ] + for item in url_items: + lines.append(" ") + lines.append(" {}".format(xml_escape(item["loc"]))) + if item.get("lastmod"): + lines.append(" {}".format(item["lastmod"])) + if item.get("changefreq"): + lines.append(" {}".format(item["changefreq"])) + if item.get("priority"): + lines.append(" {}".format(item["priority"])) + for alt in item.get("alternates") or []: + href = alt.get("href") + hreflang = alt.get("hreflang") + if not href or not hreflang: + continue + lines.append( + ' '.format( + xml_escape(hreflang), + xml_escape(href), + ) + ) + lines.append(" ") + lines.append("") + return "\n".join(lines) + + +def _build_sitemap_index_xml(entries): + lines = [ + '', + '', + ] + for item in entries: + lines.append(" ") + lines.append(" {}".format(xml_escape(item["loc"]))) + if item.get("lastmod"): + lines.append(" {}".format(item["lastmod"])) + lines.append(" ") + lines.append("") + return "\n".join(lines) + + +def _latest_forum_content_datetime(): + return db.session.query(func.max(func.coalesce(ForumPost.updated_at, ForumPost.created_at))).scalar() + + +def _forum_sitemap_total_pages(): + total_posts = ForumPost.query.count() + return max((total_posts + SITEMAP_POSTS_PER_FILE - 1) // SITEMAP_POSTS_PER_FILE, 1) + + +def _should_noindex_path(path): + target = path or "" + if target.startswith("/admin"): + return True + if target.startswith("/api/"): + return True + if target in {"/login", "/register", "/profile", "/me", "/notifications"}: + return True + if target.startswith("/notification/"): + return True + if target == "/forum/post/new": + return True + if target == "/forum/report": + return True + if target.startswith("/forum/post/") and target.endswith("/edit"): + return True + if target.startswith("/forum/comment/") and target.endswith("/edit"): + return True + return False + + +@app.after_request +def _append_response_headers(response): + response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("X-Frame-Options", "SAMEORIGIN") + if _should_noindex_path(request.path): + response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive" + return response + + def _notification_type_label(notif_type, lang=None): active_lang = lang or _get_lang() if active_lang == "en": @@ -891,6 +1164,19 @@ I18N = { "price_over500": "> ¥500", "cny": "人民币 (¥)", "usd": "美元 ($)", + "no_js_note": "已显示基础数据表;开启 JavaScript 后可使用实时筛选、排序和动态统计。", + "faq_title": "常见问题(采购前必看)", + "faq_intro": "以下信息用于预算与方案初筛,正式采购前请再次核对厂商官网。", + "faq_q1": "价格和配置数据多久更新一次?", + "faq_a1": "平台持续维护公开报价源,后台更新后会同步刷新展示与 API 缓存。", + "faq_q2": "表格价格能直接作为合同报价吗?", + "faq_a2": "不能。页面数据用于调研与比选,实际价格、账单周期与折扣条款请以厂商官网和销售合同为准。", + "faq_q3": "如何快速筛选适合企业业务的方案?", + "faq_a3": "建议先按区域和预算过滤,再结合 vCPU、内存、存储和带宽指标缩小候选范围,最后进入厂商官网确认 SLA 与网络质量。", + "cta_title": "需要更深度的采购建议?", + "cta_lede": "在社区论坛提交需求场景,或直接联系站点维护者获取更新建议。", + "cta_primary": "进入社区论坛", + "cta_secondary": "联系维护者", }, "en": { "meta_title": "Global VPS Pricing & Configuration Comparison | VPS Price", @@ -951,6 +1237,19 @@ I18N = { "price_over500": "> 500", "cny": "CNY (¥)", "usd": "USD ($)", + "no_js_note": "Base table data is already visible. Enable JavaScript for live filters, sorting, and dynamic metrics.", + "faq_title": "FAQ for Procurement Teams", + "faq_intro": "Use these answers for shortlisting. Re-check vendor websites before placing orders.", + "faq_q1": "How often are pricing and spec records updated?", + "faq_a1": "The platform continuously maintains public pricing sources. Admin updates refresh both page rendering and API cache.", + "faq_q2": "Can listed prices be treated as final contract quotes?", + "faq_a2": "No. This site is for research and shortlisting. Final pricing, billing cycles, and discounts are defined by each provider and contract.", + "faq_q3": "How should we shortlist plans for business workloads?", + "faq_a3": "Start with region and budget filters, then narrow by vCPU, memory, storage, and bandwidth. Validate SLA and network quality on the provider site.", + "cta_title": "Need Deeper Buying Guidance?", + "cta_lede": "Post your workload requirements in the community forum or contact the site maintainer directly.", + "cta_primary": "Open Community Forum", + "cta_secondary": "Contact Maintainer", }, } @@ -975,18 +1274,140 @@ def _invalidate_plans_cache(): _API_PLANS_CACHE["expires_at"] = 0.0 +def _build_home_faq_items(t): + return [ + {"question": t["faq_q1"], "answer": t["faq_a1"]}, + {"question": t["faq_q2"], "answer": t["faq_a2"]}, + {"question": t["faq_q3"], "answer": t["faq_a3"]}, + ] + + +def _build_home_schema(lang, t, canonical_url, plans_data, faq_items): + in_language = "en-US" if lang == "en" else "zh-CN" + site_root = _site_root_url() + logo_url = _absolute_url_for("static", filename="img/site-logo.svg") + og_image_url = _absolute_url_for("static", filename="img/site-logo-mark.svg") + item_list = [] + for idx, plan in enumerate(plans_data[:30], start=1): + provider_name = (plan.get("provider") or "").strip() + plan_name = (plan.get("name") or "").strip() + product_name = "{} {}".format(provider_name, plan_name).strip() or "VPS Plan {}".format(idx) + product = { + "@type": "Product", + "name": product_name, + "brand": {"@type": "Brand", "name": provider_name or SITE_NAME}, + } + region_name = (plan.get("countries") or "").strip() + if region_name: + product["category"] = region_name + official_url = (plan.get("official_url") or "").strip() + if official_url: + product["url"] = official_url + offer = {"@type": "Offer", "url": official_url or canonical_url} + if plan.get("price_cny") is not None: + offer["price"] = float(plan["price_cny"]) + offer["priceCurrency"] = "CNY" + elif plan.get("price_usd") is not None: + offer["price"] = float(plan["price_usd"]) + offer["priceCurrency"] = "USD" + if "price" in offer: + product["offers"] = offer + item_list.append({ + "@type": "ListItem", + "position": idx, + "item": product, + }) + + faq_entities = [ + { + "@type": "Question", + "name": item["question"], + "acceptedAnswer": {"@type": "Answer", "text": item["answer"]}, + } + for item in faq_items + ] + + return { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "Organization", + "@id": "{}#org".format(site_root), + "name": SITE_NAME, + "url": site_root, + "logo": logo_url, + }, + { + "@type": "WebSite", + "@id": "{}#website".format(site_root), + "url": site_root, + "name": SITE_NAME, + "inLanguage": in_language, + }, + { + "@type": "WebPage", + "@id": "{}#home".format(canonical_url), + "url": canonical_url, + "name": t["meta_title"], + "description": t["meta_description"], + "inLanguage": in_language, + "primaryImageOfPage": og_image_url, + }, + { + "@type": "ItemList", + "name": t["schema_table_name"], + "description": t["schema_table_description"], + "itemListElement": item_list, + }, + { + "@type": "FAQPage", + "mainEntity": faq_entities, + }, + ], + } + + @app.route("/") def index(): lang = _get_lang() t = I18N[lang] plans = _query_plans_for_display() plans_data = [p.to_dict() for p in plans] + canonical_url = _public_url("index", lang=lang) + alternate_links = _alternate_lang_links("index") + faq_items = _build_home_faq_items(t) + seo = { + "title": t["meta_title"], + "description": t["meta_description"], + "keywords": t["meta_keywords"], + "canonical_url": canonical_url, + "robots": "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1", + "og_type": "website", + "og_url": canonical_url, + "og_title": t["og_title"], + "og_description": t["og_description"], + "og_locale": t["og_locale"], + "og_image": _absolute_url_for("static", filename="img/site-logo-mark.svg"), + "twitter_card": "summary_large_image", + "twitter_title": t["og_title"], + "twitter_description": t["og_description"], + "alternate_links": alternate_links, + } + home_schema = _build_home_schema( + lang=lang, + t=t, + canonical_url=canonical_url, + plans_data=plans_data, + faq_items=faq_items, + ) return render_template( "index.html", - site_url=SITE_URL, + site_url=_site_root_url(), site_name=SITE_NAME, - plans_json_ld=plans_data, initial_plans_json=plans_data, + faq_items=faq_items, + seo=seo, + seo_schema=home_schema, lang=lang, t=t, ) @@ -1480,6 +1901,153 @@ def forum_index(): result_start = ((page - 1) * per_page + 1) if total_posts else 0 result_end = min(page * per_page, total_posts) if total_posts else 0 + canonical_params = { + "tab": active_tab if active_tab != "latest" else None, + "category": selected_category, + "page": page if page > 1 else None, + } + canonical_url = _public_url("forum_index", lang=lang, **canonical_params) + alternate_links = _alternate_lang_links("forum_index", **canonical_params) + prev_canonical_url = None + next_canonical_url = None + if page > 1: + prev_params = dict(canonical_params) + prev_page = page - 1 + prev_params["page"] = prev_page if prev_page > 1 else None + prev_canonical_url = _public_url("forum_index", lang=lang, **prev_params) + if page < total_pages: + next_params = dict(canonical_params) + next_params["page"] = page + 1 + next_canonical_url = _public_url("forum_index", lang=lang, **next_params) + + if selected_category: + forum_title = _pick_lang( + "{} 讨论区 | 云价眼论坛".format(selected_category), + "{} Discussions | VPS Forum".format(selected_category), + lang, + ) + forum_heading = _pick_lang( + "{} · 论坛分类".format(selected_category), + "{} · Forum Category".format(selected_category), + lang, + ) + else: + forum_title = _pick_lang("VPS 社区论坛 | 云价眼", "VPS Community Forum | VPS Price", lang) + forum_heading = _pick_lang("VPS 社区论坛", "VPS Community Forum", lang) + if page > 1: + forum_title = "{} - {}".format( + forum_title, + _pick_lang("第 {} 页".format(page), "Page {}".format(page), lang), + ) + if search_query: + forum_description = _pick_lang( + "论坛搜索结果:{}。该页面主要用于站内检索。".format(search_query), + "Forum search results for '{}'. This page is intended for on-site search.".format(search_query), + lang, + ) + forum_intro = _pick_lang( + "搜索词:{}。建议进一步按分类或标签缩小结果范围。".format(search_query), + "Search query: '{}'. Narrow down with categories or topic tags for better results.".format(search_query), + lang, + ) + elif selected_category: + forum_description = _forum_category_description(selected_category, lang) + forum_intro = forum_description + else: + forum_description = _forum_category_description(None, lang) + tab_intro_map = { + "latest": _pick_lang( + "按最新活跃度浏览主题,快速跟进持续更新的讨论。", + "Browse by latest activity to track ongoing discussions.", + lang, + ), + "new": _pick_lang( + "查看最近发布的新主题,及时参与新话题。", + "See newly published topics and join early conversations.", + lang, + ), + "hot": _pick_lang( + "按热度排序,优先阅读高互动的热门讨论。", + "Sorted by engagement to surface high-signal discussions.", + lang, + ), + } + forum_intro = tab_intro_map.get(active_tab) or forum_description + + noindex_listing = bool(search_query or per_page != 20) + forum_feed_url = _public_url("forum_feed", lang=lang) + seo = { + "title": forum_title, + "description": forum_description, + "keywords": _forum_index_keywords(lang, active_tab=active_tab, selected_category=selected_category), + "canonical_url": canonical_url, + "prev_canonical_url": prev_canonical_url, + "next_canonical_url": next_canonical_url, + "robots": "noindex,follow" if noindex_listing else "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1", + "og_type": "website", + "og_url": canonical_url, + "og_title": forum_title, + "og_description": forum_description, + "og_image": _absolute_url_for("static", filename="img/site-logo-mark.svg"), + "twitter_card": "summary_large_image", + "twitter_title": forum_title, + "twitter_description": forum_description, + "alternate_links": alternate_links, + "feed_url": forum_feed_url, + } + + list_items = [] + latest_activity_at = None + for idx, card in enumerate(post_cards, start=1): + post_obj = card.get("post") + if not post_obj: + continue + post_url = _public_url("forum_post_detail", lang=lang, post_id=post_obj.id) + list_items.append({ + "@type": "ListItem", + "position": idx, + "name": post_obj.title, + "url": post_url, + }) + activity_at = card.get("latest_activity") or post_obj.updated_at or post_obj.created_at + if activity_at and (latest_activity_at is None or activity_at > latest_activity_at): + latest_activity_at = activity_at + + breadcrumb_schema = _forum_breadcrumb_schema(lang=lang, selected_category=selected_category) + breadcrumb_schema["@id"] = "{}#breadcrumb".format(canonical_url) + collection_schema = { + "@type": "CollectionPage", + "@id": "{}#collection".format(canonical_url), + "name": forum_title, + "description": forum_description, + "url": canonical_url, + "inLanguage": "en-US" if lang == "en" else "zh-CN", + "breadcrumb": {"@id": breadcrumb_schema["@id"]}, + "isPartOf": {"@type": "WebSite", "name": SITE_NAME, "url": _site_root_url()}, + } + if latest_activity_at: + collection_schema["dateModified"] = _iso8601_utc(latest_activity_at) + if not search_query: + collection_schema["potentialAction"] = { + "@type": "SearchAction", + "target": "{}?q={{q}}".format(_public_url("forum_index", lang=lang)), + "query-input": "required name=q", + } + + seo_graph = [collection_schema, breadcrumb_schema] + if list_items: + item_list_schema = { + "@type": "ItemList", + "@id": "{}#items".format(canonical_url), + "itemListElement": list_items, + } + collection_schema["mainEntity"] = {"@id": item_list_schema["@id"]} + seo_graph.append(item_list_schema) + + seo_schema = { + "@context": "https://schema.org", + "@graph": seo_graph, + } return render_template( "forum/index.html", @@ -1527,6 +2095,11 @@ def forum_index(): per_page_options=per_page_options, message=request.args.get("msg") or "", error=request.args.get("error") or "", + forum_heading=forum_heading, + forum_intro=forum_intro, + forum_feed_url=forum_feed_url, + seo=seo, + seo_schema=seo_schema, ) @@ -1650,6 +2223,7 @@ def forum_post_delete(post_id): @app.route("/forum/post/") def forum_post_detail(post_id): + lang = _get_lang() post = ForumPost.query.get_or_404(post_id) current_user = _get_current_user() viewed_posts = session.get("viewed_posts") or [] @@ -1684,6 +2258,125 @@ def forum_post_detail(post_id): liked_by_me = "like" in kinds bookmarked_by_me = "bookmark" in kinds sidebar = _forum_sidebar_data() + 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: + post_excerpt = _pick_lang("论坛主题详情页。", "Discussion topic detail page.", lang) + post_category = post.category or _pick_lang("综合讨论", "General", lang) + post_keywords = ", ".join(dict.fromkeys([ + post_category, + _pick_lang("VPS论坛", "VPS forum", lang), + _pick_lang("VPS讨论", "VPS discussion", lang), + _pick_lang("云服务器评测", "cloud server review", lang), + ])) + published_time = _iso8601_utc(post.created_at) + modified_time = _iso8601_utc(post.updated_at or post.created_at) + comments_count = len(comments) + forum_feed_url = _public_url("forum_feed", lang=lang) + seo_title = _pick_lang( + "{} - 论坛主题 | 云价眼".format(post.title), + "{} - Forum Topic | VPS Price".format(post.title), + lang, + ) + seo = { + "title": seo_title, + "description": post_excerpt, + "keywords": post_keywords, + "canonical_url": canonical_url, + "robots": "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1", + "og_type": "article", + "og_url": canonical_url, + "og_title": seo_title, + "og_description": post_excerpt, + "og_image": _absolute_url_for("static", filename="img/site-logo-mark.svg"), + "twitter_card": "summary_large_image", + "twitter_title": seo_title, + "twitter_description": post_excerpt, + "article_published_time": published_time, + "article_modified_time": modified_time, + "article_section": post_category, + "feed_url": forum_feed_url, + "alternate_links": _alternate_lang_links("forum_post_detail", post_id=post.id), + } + author_name = ( + post.author_rel.username + if post.author_rel and post.author_rel.username + else _pick_lang("已注销用户", "Deleted user", lang) + ) + post_schema = { + "@type": "DiscussionForumPosting", + "@id": "{}#topic".format(canonical_url), + "headline": post.title, + "description": post_excerpt, + "articleSection": post_category, + "keywords": post_keywords, + "mainEntityOfPage": canonical_url, + "url": canonical_url, + "datePublished": published_time, + "dateModified": modified_time, + "author": {"@type": "Person", "name": author_name}, + "publisher": { + "@type": "Organization", + "name": SITE_NAME, + "url": _site_root_url(), + "logo": { + "@type": "ImageObject", + "url": _absolute_url_for("static", filename="img/site-logo.svg"), + }, + }, + "commentCount": comments_count, + "interactionStatistic": [ + { + "@type": "InteractionCounter", + "interactionType": "https://schema.org/ViewAction", + "userInteractionCount": int(post.view_count or 0), + }, + { + "@type": "InteractionCounter", + "interactionType": "https://schema.org/CommentAction", + "userInteractionCount": comments_count, + }, + { + "@type": "InteractionCounter", + "interactionType": "https://schema.org/LikeAction", + "userInteractionCount": int(like_count or 0), + }, + ], + "inLanguage": "en-US" if lang == "en" else "zh-CN", + "isPartOf": {"@type": "WebSite", "name": SITE_NAME, "url": _site_root_url()}, + } + + comment_entities = [] + for c in 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: + continue + comment_item = { + "@type": "Comment", + "text": text_excerpt, + "dateCreated": _iso8601_utc(c.created_at), + "author": {"@type": "Person", "name": author}, + } + if c.id: + comment_item["url"] = "{}#comment-{}".format(canonical_url, c.id) + comment_entities.append(comment_item) + if comment_entities: + post_schema["comment"] = comment_entities + + breadcrumb_schema = _forum_breadcrumb_schema( + lang=lang, + selected_category=post.category, + post=post, + post_url=canonical_url, + ) + breadcrumb_schema["@id"] = "{}#breadcrumb".format(canonical_url) + post_schema["breadcrumb"] = {"@id": breadcrumb_schema["@id"]} + + seo_schema = { + "@context": "https://schema.org", + "@graph": [post_schema, breadcrumb_schema], + } return render_template( "forum/post_detail.html", post=post, @@ -1696,6 +2389,8 @@ def forum_post_detail(post_id): sidebar=sidebar, message=request.args.get("msg") or "", error=request.args.get("error") or "", + seo=seo, + seo_schema=seo_schema, ) @@ -1912,40 +2607,315 @@ def forum_report_create(): return _forum_redirect_with_msg(report_post_id, "举报已提交,感谢反馈") +@app.route("/forum/feed.xml") +def forum_feed(): + lang = _get_lang() + latest_activity_expr = func.coalesce(ForumPost.updated_at, ForumPost.created_at) + rows = ( + db.session.query( + ForumPost, + User.username.label("author_name"), + ) + .outerjoin(User, User.id == ForumPost.user_id) + .order_by(latest_activity_expr.desc(), ForumPost.id.desc()) + .limit(120) + .all() + ) + + channel_title = _pick_lang("云价眼论坛最新主题", "VPS Price Forum Latest Topics", lang) + channel_description = _pick_lang( + "按最新活跃度输出论坛主题 RSS 订阅,便于跟踪 VPS 讨论更新。", + "RSS feed of the latest forum activity to track VPS discussions.", + lang, + ) + channel_link = _public_url("forum_index", lang=lang) + self_feed_url = _public_url("forum_feed", lang=lang) + latest_time = None + if rows: + p = rows[0][0] + latest_time = p.updated_at or p.created_at + last_build_date = _rfc2822_utc(latest_time or datetime.now(timezone.utc)) + + lines = [ + '', + '', + " ", + " {}".format(xml_escape(channel_title)), + " {}".format(xml_escape(channel_description)), + " {}".format(xml_escape(channel_link)), + " {}".format("en-us" if lang == "en" else "zh-cn"), + " {}".format(xml_escape(last_build_date)), + ' '.format(xml_escape(self_feed_url)), + ] + for post, author_name in rows: + post_url = _public_url("forum_post_detail", lang=lang, post_id=post.id) + pub_date = _rfc2822_utc(post.updated_at or post.created_at) or last_build_date + author = author_name or _pick_lang("匿名用户", "Anonymous", lang) + summary = _plain_excerpt(post.content or "", limit=260) + category = post.category or _pick_lang("综合讨论", "General", lang) + lines.extend([ + " ", + " {}".format(xml_escape(post.title or _pick_lang("未命名主题", "Untitled topic", lang))), + " {}".format(xml_escape(summary)), + " {}".format(xml_escape(post_url)), + " {}".format(xml_escape(post_url)), + " {}".format(xml_escape(author)), + " {}".format(xml_escape(category)), + " {}".format(xml_escape(pub_date)), + " ", + ]) + lines.extend([ + " ", + "", + ]) + xml = "\n".join(lines) + + resp = make_response(xml) + resp.mimetype = "application/rss+xml" + resp.headers["Cache-Control"] = "public, max-age=900" + return resp + + +# ---------- 法务页面 ---------- +@app.route("/privacy") +def privacy_policy(): + lang = _get_lang() + page_title = _pick_lang("隐私政策 | 云价眼", "Privacy Policy | VPS Price", lang) + page_description = _pick_lang( + "了解云价眼如何收集、使用和保护站点访客与论坛用户数据。", + "How VPS Price collects, uses, and protects visitor and forum user data.", + lang, + ) + canonical_url = _public_url("privacy_policy", lang=lang) + seo = { + "title": page_title, + "description": page_description, + "canonical_url": canonical_url, + "robots": "index,follow,max-image-preview:large", + "og_type": "article", + "og_url": canonical_url, + "og_title": page_title, + "og_description": page_description, + "og_image": _absolute_url_for("static", filename="img/site-logo-mark.svg"), + "twitter_card": "summary", + "twitter_title": page_title, + "twitter_description": page_description, + "alternate_links": _alternate_lang_links("privacy_policy"), + } + seo_schema = { + "@context": "https://schema.org", + "@type": "WebPage", + "name": page_title, + "description": page_description, + "url": canonical_url, + "inLanguage": "en-US" if lang == "en" else "zh-CN", + } + return render_template( + "privacy.html", + seo=seo, + seo_schema=seo_schema, + updated_on="2026-02-10", + ) + + +@app.route("/terms") +def terms_of_service(): + lang = _get_lang() + page_title = _pick_lang("服务条款 | 云价眼", "Terms of Service | VPS Price", lang) + page_description = _pick_lang( + "查看云价眼的服务范围、免责声明与论坛使用规范。", + "Read the VPS Price service scope, disclaimers, and forum usage rules.", + lang, + ) + canonical_url = _public_url("terms_of_service", lang=lang) + seo = { + "title": page_title, + "description": page_description, + "canonical_url": canonical_url, + "robots": "index,follow,max-image-preview:large", + "og_type": "article", + "og_url": canonical_url, + "og_title": page_title, + "og_description": page_description, + "og_image": _absolute_url_for("static", filename="img/site-logo-mark.svg"), + "twitter_card": "summary", + "twitter_title": page_title, + "twitter_description": page_description, + "alternate_links": _alternate_lang_links("terms_of_service"), + } + seo_schema = { + "@context": "https://schema.org", + "@type": "WebPage", + "name": page_title, + "description": page_description, + "url": canonical_url, + "inLanguage": "en-US" if lang == "en" else "zh-CN", + } + return render_template( + "terms.html", + seo=seo, + seo_schema=seo_schema, + updated_on="2026-02-10", + ) + + # ---------- SEO ---------- @app.route("/sitemap.xml") def sitemap(): - from flask import make_response - url = SITE_URL.rstrip("/") - xml = f''' - - - {url}/ - weekly - 1.0 - - - {url}/forum - daily - 0.9 - -''' + latest_forum_dt = _latest_forum_content_datetime() + sitemap_lastmod = _iso8601_utc(latest_forum_dt) + total_pages = _forum_sitemap_total_pages() + entries = [{ + "loc": _absolute_url_for("sitemap_static"), + "lastmod": sitemap_lastmod, + }] + for lang_code in ("zh", "en"): + for page in range(1, total_pages + 1): + entries.append({ + "loc": _absolute_url_for("sitemap_forum_page", lang_code=lang_code, page=page), + "lastmod": sitemap_lastmod, + }) + xml = _build_sitemap_index_xml(entries) resp = make_response(xml) resp.mimetype = "application/xml" + resp.headers["Cache-Control"] = "public, max-age=1800" + return resp + + +@app.route("/sitemap-static.xml") +def sitemap_static(): + latest_forum_dt = _latest_forum_content_datetime() + latest_forum_lastmod = _iso8601_utc(latest_forum_dt) + urls = [] + + def add_page(endpoint, changefreq, priority, params=None, lastmod=None): + values = params or {} + alternates = _sitemap_alternates(endpoint, **values) + for lang_code in ("zh", "en"): + urls.append({ + "loc": _public_url(endpoint, lang=lang_code, **values), + "changefreq": changefreq, + "priority": priority, + "lastmod": lastmod, + "alternates": alternates, + }) + + add_page("index", "daily", "1.0") + add_page("forum_index", "daily", "0.9", lastmod=latest_forum_lastmod) + add_page("forum_feed", "hourly", "0.4", lastmod=latest_forum_lastmod) + add_page("forum_index", "daily", "0.8", params={"tab": "new"}, lastmod=latest_forum_lastmod) + add_page("forum_index", "daily", "0.8", params={"tab": "hot"}, lastmod=latest_forum_lastmod) + add_page("privacy_policy", "monthly", "0.3", lastmod="2026-02-10T00:00:00Z") + add_page("terms_of_service", "monthly", "0.3", lastmod="2026-02-10T00:00:00Z") + + category_rows = ( + db.session.query( + ForumPost.category, + func.max(func.coalesce(ForumPost.updated_at, ForumPost.created_at)).label("latest_at"), + ) + .filter(ForumPost.category.isnot(None), ForumPost.category != "") + .group_by(ForumPost.category) + .order_by(func.count(ForumPost.id).desc(), ForumPost.category.asc()) + .limit(300) + .all() + ) + for category_name, latest_at in category_rows: + add_page( + "forum_index", + "daily", + "0.75", + params={"category": category_name}, + lastmod=_iso8601_utc(latest_at), + ) + + xml = _build_sitemap_urlset_xml(urls) + resp = make_response(xml) + resp.mimetype = "application/xml" + resp.headers["Cache-Control"] = "public, max-age=1800" + return resp + + +@app.route("/sitemap-forum--.xml") +def sitemap_forum_page(lang_code, page): + normalized_lang = (lang_code or "").strip().lower() + if normalized_lang not in {"zh", "en"}: + abort(404) + total_pages = _forum_sitemap_total_pages() + if page < 1 or page > total_pages: + abort(404) + + offset = (page - 1) * SITEMAP_POSTS_PER_FILE + rows = ( + db.session.query(ForumPost.id, ForumPost.updated_at, ForumPost.created_at) + .order_by(ForumPost.updated_at.desc(), ForumPost.id.desc()) + .offset(offset) + .limit(SITEMAP_POSTS_PER_FILE) + .all() + ) + urls = [] + for post_id, updated_at, created_at in rows: + lastmod = _iso8601_utc(updated_at or created_at) + urls.append({ + "loc": _public_url("forum_post_detail", lang=normalized_lang, post_id=post_id), + "changefreq": "weekly", + "priority": "0.8", + "lastmod": lastmod, + "alternates": _sitemap_alternates("forum_post_detail", post_id=post_id), + }) + + xml = _build_sitemap_urlset_xml(urls) + resp = make_response(xml) + resp.mimetype = "application/xml" + resp.headers["Cache-Control"] = "public, max-age=1800" return resp @app.route("/robots.txt") def robots(): - from flask import make_response - url = SITE_URL.rstrip("/") - txt = f"""User-agent: * + txt = """User-agent: * Allow: / +Allow: /forum/feed.xml +Disallow: /admin/ +Disallow: /login +Disallow: /register +Disallow: /profile +Disallow: /me +Disallow: /notifications +Disallow: /notification/ +Disallow: /forum/post/new +Disallow: /forum/post/*/edit +Disallow: /forum/comment/*/edit +Disallow: /forum/report +Disallow: /api/ +Disallow: /*?*q= -Sitemap: {url}/sitemap.xml -""" +Sitemap: {}/sitemap.xml +""".format(_site_root_url()) resp = make_response(txt) resp.mimetype = "text/plain" + resp.headers["Cache-Control"] = "public, max-age=3600" + return resp + + +@app.route("/ads.txt") +def ads_txt(): + content = (os.environ.get("ADS_TXT_CONTENT") or "").strip() + if content: + from flask import make_response + + body = content if content.endswith("\n") else "{}\n".format(content) + resp = make_response(body) + resp.mimetype = "text/plain" + resp.headers["Cache-Control"] = "public, max-age=3600" + return resp + ads_file = os.path.join(app.static_folder or "", "ads.txt") + if os.path.isfile(ads_file): + return send_from_directory(app.static_folder or "", "ads.txt") + from flask import make_response + + resp = make_response("# Configure ADS_TXT_CONTENT or create static/ads.txt\n") + resp.mimetype = "text/plain" + resp.headers["Cache-Control"] = "public, max-age=600" return resp diff --git a/config.py b/config.py index cf9b588..1f6d598 100644 --- a/config.py +++ b/config.py @@ -25,7 +25,15 @@ class Config: SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL") or _mysql_uri() SQLALCHEMY_TRACK_MODIFICATIONS = False SITE_URL = os.environ.get("SITE_URL") or "https://vps.ddrwode.cn" - SITE_NAME = "云价眼" + SITE_NAME = os.environ.get("SITE_NAME") or "云价眼" + PREFERRED_URL_SCHEME = "https" + SEND_FILE_MAX_AGE_DEFAULT = int(os.environ.get("STATIC_CACHE_SECONDS") or "604800") + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = os.environ.get("SESSION_COOKIE_SAMESITE") or "Lax" + SESSION_COOKIE_SECURE = ( + os.environ.get("SESSION_COOKIE_SECURE") + or ("1" if (os.environ.get("FLASK_ENV") or "").lower() == "production" else "0") + ) == "1" # 兼容直接 from config import XXX diff --git a/static/ads.txt b/static/ads.txt new file mode 100644 index 0000000..5412183 --- /dev/null +++ b/static/ads.txt @@ -0,0 +1,2 @@ +# Add your ad network records in the following format: +# example.com, pub-XXXXXXXXXXXXXXXX, DIRECT, f08c47fec0942fa0 diff --git a/static/css/forum.css b/static/css/forum.css index 564cc40..be315bc 100644 --- a/static/css/forum.css +++ b/static/css/forum.css @@ -33,6 +33,20 @@ gap: 1rem; } +.forum-header-inner-with-center { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); +} + +.forum-header-inner-with-center .forum-header-left { + grid-column: 1; +} + +.forum-header-inner-with-center .forum-header-right { + grid-column: 3; + justify-self: end; +} + .forum-header-left { display: flex; align-items: center; @@ -64,6 +78,33 @@ white-space: nowrap; } +.forum-top-nav { + grid-column: 2; + justify-self: center; + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.forum-top-nav a { + color: var(--text-muted); + text-decoration: none; + font-size: 0.88rem; + font-family: var(--font-mono); + padding: 0.34rem 0.62rem; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg-card); + transition: var(--transition); +} + +.forum-top-nav a:hover, +.forum-top-nav a.active { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-glow); +} + .forum-primary-nav { display: flex; align-items: center; @@ -124,6 +165,99 @@ width: 100%; } +.visually-hidden { + position: absolute !important; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.forum-hero { + padding: 0.9rem 0.95rem; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: var(--radius-lg); + margin-bottom: 0.9rem; + background: + linear-gradient(145deg, rgba(3, 105, 161, 0.07), rgba(15, 23, 42, 0.04)), + var(--bg-card); + box-shadow: var(--shadow); +} + +.forum-hero-kicker { + margin: 0; + color: var(--accent); + font-size: 0.75rem; + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.forum-hero h1 { + margin: 0.28rem 0 0.38rem; + font-size: clamp(1.18rem, 2vw, 1.52rem); + color: var(--text); + line-height: 1.3; +} + +.forum-hero p { + margin: 0; + color: var(--text-muted); + max-width: 72ch; + font-size: 0.88rem; + line-height: 1.62; +} + +.forum-hero-meta { + margin-top: 0.7rem; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.55rem; + color: var(--text-muted); + font-size: 0.78rem; +} + +.forum-hero-meta span { + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.2rem 0.54rem; + background: var(--bg-card); +} + +.forum-breadcrumb { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.38rem; + margin-bottom: 0.72rem; + font-size: 0.76rem; + color: var(--text-muted); +} + +.forum-breadcrumb a { + color: var(--accent); + text-decoration: none; +} + +.forum-breadcrumb a:hover { + text-decoration: underline; + text-underline-offset: 2px; +} + +a:focus-visible, +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + .forum-topline { display: flex; align-items: center; @@ -335,29 +469,57 @@ display: flex; align-items: center; justify-content: space-between; + flex-wrap: wrap; gap: 0.75rem; - padding: 0.58rem 0.92rem; + padding: 0.72rem 0.92rem; border-top: 1px solid var(--border); - background: var(--bg-card); + background: linear-gradient(180deg, rgba(248, 250, 252, 0.75), rgba(248, 250, 252, 0.3)); +} + +.topic-footer-controls { + display: inline-flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.45rem; + margin-left: auto; } .page-size-form { display: inline-flex; align-items: center; gap: 0.4rem; + padding: 0.22rem 0.3rem; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-elevated); font-size: 0.78rem; color: var(--text-muted); } +.page-size-form label, +.page-size-form span { + color: var(--text-muted); + font-size: 0.76rem; + font-weight: 600; +} + .page-size-form select { border: 1px solid var(--border); - border-radius: 6px; - background: var(--bg-elevated); + border-radius: 8px; + background: var(--bg-card); color: var(--text); - font-size: 0.82rem; - line-height: 1.2; - padding: 0.26rem 0.42rem; + font-size: 0.8rem; + font-weight: 600; + min-height: 32px; + padding: 0.22rem 0.48rem; cursor: pointer; + transition: var(--transition); +} + +.page-size-form select:hover { + border-color: var(--accent); + background: var(--accent-glow); } .page-size-form select:focus { @@ -498,25 +660,35 @@ .forum-pagination { display: flex; + align-items: center; flex-wrap: wrap; gap: 0.4rem; padding: 0.78rem 0.92rem; border-top: 1px solid var(--border); + background: var(--bg-card); +} + +.forum-pagination-inline { + padding: 0; + border-top: none; + background: transparent; } .page-link { display: inline-flex; align-items: center; justify-content: center; - min-width: 34px; - height: 30px; - padding: 0 0.55rem; + min-width: 36px; + height: 32px; + padding: 0 0.6rem; text-decoration: none; border: 1px solid var(--border); - border-radius: 6px; + border-radius: 8px; color: var(--text-muted); background: var(--bg-card); font-size: 0.8rem; + font-weight: 600; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); transition: var(--transition); } @@ -524,17 +696,22 @@ color: var(--accent); border-color: var(--accent); background: var(--accent-glow); + transform: translateY(-1px); + box-shadow: 0 6px 14px rgba(3, 105, 161, 0.14); } .page-link.active { color: #fff; border-color: var(--accent); background: var(--accent); + box-shadow: 0 8px 16px rgba(3, 105, 161, 0.2); } .page-link.disabled { color: var(--text-muted); opacity: 0.5; + pointer-events: none; + box-shadow: none; } .forum-sidebar { @@ -1126,6 +1303,24 @@ } @media (max-width: 768px) { + .forum-hero { + padding: 0.78rem 0.8rem; + } + + .forum-hero-meta { + gap: 0.42rem; + } + + .forum-header-inner-with-center { + display: flex; + } + + .forum-top-nav { + width: 100%; + justify-content: center; + order: 2; + } + .forum-header { padding: 1.5rem 1rem; } @@ -1182,6 +1377,21 @@ align-items: flex-start; } + .topic-footer-controls { + width: 100%; + margin-left: 0; + justify-content: flex-start; + } + + .forum-pagination-inline { + width: 100%; + } + + .page-size-form { + width: 100%; + justify-content: flex-start; + } + .topic-head { grid-template-columns: minmax(0, 1fr) 64px 64px; } @@ -1241,6 +1451,7 @@ } .forum-primary-nav a, +.forum-top-nav a, .forum-link { font-weight: 600; } @@ -1320,9 +1531,36 @@ background: rgba(3, 105, 161, 0.08); } +.topic-footer { + border-top-color: rgba(148, 163, 184, 0.3); + background: linear-gradient(180deg, rgba(248, 250, 252, 0.78), rgba(248, 250, 252, 0.42)); +} + +.page-size-form { + border-color: rgba(148, 163, 184, 0.38); + background: rgba(255, 255, 255, 0.9); +} + +.page-size-form label, +.page-size-form span { + color: #475569; +} + +.page-size-form select { + border-color: rgba(148, 163, 184, 0.38); + background: rgba(255, 255, 255, 0.9); + color: #0f172a; +} + +.page-size-form select:hover { + border-color: rgba(3, 105, 161, 0.45); + background: rgba(3, 105, 161, 0.08); +} + .page-link { border-color: rgba(148, 163, 184, 0.38); background: rgba(255, 255, 255, 0.9); + color: #334155; } .page-link.active { diff --git a/static/css/style.css b/static/css/style.css index 8bef15d..eefc5d0 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -2965,3 +2965,233 @@ html { border-radius: 12px; } } + +/* ========================================================================== + SEO + Compliance Sections + ========================================================================== */ + +a:focus-visible, +button:focus-visible, +input:focus-visible, +select:focus-visible, +summary:focus-visible { + outline: 2px solid #0369a1; + outline-offset: 2px; +} + +.no-js-note { + margin: 0; + padding: 0.72rem 0.86rem; + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.3); + background: rgba(241, 245, 249, 0.9); + color: #334155; + font-size: 0.83rem; +} + +.seo-faq { + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 16px; + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08), 0 16px 26px rgba(15, 23, 42, 0.06); + padding: 1rem; +} + +.seo-faq .section-head h2 { + margin: 0; + color: #020617; + font-size: 1.02rem; +} + +.seo-faq .section-head p { + margin: 0.34rem 0 0; + color: #64748b; + font-size: 0.84rem; +} + +.faq-grid { + margin-top: 0.78rem; + display: grid; + gap: 0.62rem; +} + +.faq-item { + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 12px; + background: #ffffff; + padding: 0.66rem 0.78rem; +} + +.faq-item summary { + cursor: pointer; + font-weight: 600; + color: #0f172a; + list-style: none; +} + +.faq-item summary::-webkit-details-marker { + display: none; +} + +.faq-item p { + margin: 0.5rem 0 0; + color: #334155; + font-size: 0.88rem; +} + +.commercial-cta { + border-radius: 16px; + border: 1px solid rgba(15, 23, 42, 0.14); + background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); + color: #f8fafc; + padding: 1rem; +} + +.commercial-cta h2 { + margin: 0; + font-size: 1.08rem; +} + +.commercial-cta p { + margin: 0.4rem 0 0; + color: rgba(226, 232, 240, 0.9); + font-size: 0.9rem; +} + +.cta-actions { + margin-top: 0.78rem; + display: flex; + flex-wrap: wrap; + gap: 0.58rem; +} + +.cta-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 38px; + border-radius: 10px; + padding: 0.48rem 0.84rem; + font-size: 0.86rem; + font-weight: 600; + text-decoration: none; + transition: all 0.2s ease; +} + +.cta-btn-primary { + background: #0ea5e9; + color: #f8fafc; +} + +.cta-btn-primary:hover { + background: #0284c7; + transform: translateY(-1px); +} + +.cta-btn-secondary { + border: 1px solid rgba(226, 232, 240, 0.45); + color: #f8fafc; +} + +.cta-btn-secondary:hover { + border-color: rgba(226, 232, 240, 0.8); + background: rgba(15, 23, 42, 0.28); +} + +.footer-links { + margin: 0.5rem 0; + display: inline-flex; + flex-wrap: wrap; + gap: 0.7rem; + justify-content: center; +} + +.footer-links a { + color: #334155; + text-decoration: none; + font-size: 0.82rem; + font-weight: 600; +} + +.footer-links a:hover { + color: #0f172a; + text-decoration: underline; +} + +.policy-page { + background: + radial-gradient(1100px 460px at 10% -10%, rgba(3, 105, 161, 0.07), transparent 60%), + radial-gradient(900px 430px at 90% -15%, rgba(15, 23, 42, 0.08), transparent 62%), + var(--bg); +} + +.policy-shell { + max-width: 920px; + width: 100%; + margin: 0 auto; + padding-top: 1.25rem; + padding-bottom: 1.7rem; +} + +.policy-card { + border-radius: 16px; + border: 1px solid rgba(148, 163, 184, 0.3); + background: rgba(255, 255, 255, 0.95); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08), 0 14px 26px rgba(15, 23, 42, 0.07); + padding: 1.1rem; +} + +.policy-card h1 { + margin: 0; + font-size: clamp(1.3rem, 2.5vw, 1.82rem); + letter-spacing: -0.02em; +} + +.policy-updated { + margin: 0.45rem 0 1rem; + color: #64748b; + font-size: 0.85rem; +} + +.policy-card section + section { + margin-top: 0.82rem; +} + +.policy-card h2 { + margin: 0; + font-size: 0.95rem; + color: #0f172a; +} + +.policy-card p { + margin: 0.38rem 0 0; + color: #334155; + font-size: 0.91rem; + line-height: 1.6; +} + +.policy-card a { + color: #0369a1; + text-decoration: none; +} + +.policy-card a:hover { + text-decoration: underline; +} + +@media (max-width: 768px) { + .seo-faq, + .commercial-cta, + .policy-card { + border-radius: 14px; + padding: 0.9rem; + } + + .cta-actions { + flex-direction: column; + } + + .cta-btn { + width: 100%; + } +} diff --git a/static/js/main-simple.js b/static/js/main-simple.js index 31c16cb..67e36af 100644 --- a/static/js/main-simple.js +++ b/static/js/main-simple.js @@ -318,7 +318,7 @@ '' + plan.traffic + '' + '' + displayPrice + '' + '' + - '' + btnText + '' + + '' + btnText + '' + ''; return tr; diff --git a/templates/auth/login.html b/templates/auth/login.html index 2b41af3..5b83849 100644 --- a/templates/auth/login.html +++ b/templates/auth/login.html @@ -3,6 +3,7 @@ + {{ l('用户登录', 'Login') }} - {{ l('云价眼', 'VPS Price') }} diff --git a/templates/auth/register.html b/templates/auth/register.html index a39f980..a49020b 100644 --- a/templates/auth/register.html +++ b/templates/auth/register.html @@ -3,6 +3,7 @@ + {{ l('用户注册', 'Register') }} - {{ l('云价眼', 'VPS Price') }} diff --git a/templates/forum/comment_form.html b/templates/forum/comment_form.html index a6d7ba7..0f8ca75 100644 --- a/templates/forum/comment_form.html +++ b/templates/forum/comment_form.html @@ -3,6 +3,7 @@ + {{ l('编辑评论', 'Edit Comment') }} - {{ l('论坛', 'Forum') }} diff --git a/templates/forum/index.html b/templates/forum/index.html index 75a46f2..f4e2a63 100644 --- a/templates/forum/index.html +++ b/templates/forum/index.html @@ -3,8 +3,35 @@ - {{ l('论坛', 'Forum') }} - {{ l('云价眼', 'VPS Price') }} + {{ seo.title if seo else (l('论坛', 'Forum') ~ ' - ' ~ l('云价眼', 'VPS Price')) }} + {% if seo %} + + + + + + {% if seo.prev_canonical_url %}{% endif %} + {% if seo.next_canonical_url %}{% endif %} + {% for hreflang, href in seo.alternate_links.items() %} + + {% endfor %} + {% if seo.feed_url %} + + {% endif %} + + + + + + + + + + {% if seo_schema %} + + {% endif %} + {% endif %} @@ -12,20 +39,17 @@ {% set cards = post_cards if post_cards is defined else [] %} {% set sb = sidebar if sidebar is defined else {'total_users': 0, 'total_posts': 0, 'total_comments': 0, 'category_counts': [], 'active_users': []} %}
-
+
+

{{ l('商业化社区入口', 'Commercial Community Hub') }}

+

{{ forum_heading if forum_heading is defined else l('VPS 社区论坛', 'VPS Community Forum') }}

+

{{ forum_intro if forum_intro is defined else l('聚合 VPS 评测、运维经验与采购讨论。', 'A discussion space for VPS reviews, operations, and buying decisions.') }}

+
+ {{ l('帖子总数', 'Total topics') }} {{ sb.total_posts }} + {{ l('活跃用户', 'Active users') }} {{ sb.total_users }} + {{ l('RSS 订阅', 'RSS Feed') }} +
+
{% for item in tab_links %} @@ -77,7 +111,8 @@ {% if selected_category %} {% endif %} - + + {% if search_query %} {{ l('清空搜索', 'Clear Search') }} @@ -86,7 +121,7 @@ {{ l('重置全部', 'Reset All') }} {% endif %} -
+
+
-
+
+

{{ l('论坛主题列表', 'Forum topic list') }}

{{ l('主题', 'Topic') }}
{{ l('回复', 'Replies') }}
@@ -115,7 +151,7 @@ -
- - {% if selected_category %} - - {% endif %} - {% if search_query %} - - {% endif %} - - - {{ l('条', 'items') }} - -
+ {% if has_next %} + {{ l('下一页', 'Next') }} + {% else %} + {{ l('下一页', 'Next') }} + {% endif %} + + {% endif %} +
+ + {% if selected_category %} + + {% endif %} + {% if search_query %} + + {% endif %} + + + {{ l('条', 'items') }} + +
+
{% endif %} diff --git a/templates/forum/notifications.html b/templates/forum/notifications.html index 3e23212..2479451 100644 --- a/templates/forum/notifications.html +++ b/templates/forum/notifications.html @@ -3,6 +3,7 @@ + {{ l('通知中心', 'Notifications') }} - {{ l('论坛', 'Forum') }} diff --git a/templates/forum/post_detail.html b/templates/forum/post_detail.html index 9cc3449..9ef0c7d 100644 --- a/templates/forum/post_detail.html +++ b/templates/forum/post_detail.html @@ -3,8 +3,36 @@ - {{ post.title }} - {{ l('论坛', 'Forum') }} + {{ seo.title if seo else (post.title ~ ' - ' ~ l('论坛', 'Forum')) }} + {% if seo %} + + + + + + {% for hreflang, href in seo.alternate_links.items() %} + + {% endfor %} + {% if seo.feed_url %} + + {% endif %} + + + + + + + + + + + + + {% if seo_schema %} + + {% endif %} + {% endif %} @@ -45,6 +73,17 @@
+
@@ -53,7 +92,9 @@ {% if post.is_pinned %}{{ l('置顶', 'Pinned') }}{% endif %} {% if post.is_featured %}{{ l('精华', 'Featured') }}{% endif %} {% if post.is_locked %}{{ l('锁帖', 'Locked') }}{% endif %} - {{ post.created_at.strftime('%Y-%m-%d %H:%M') if post.created_at else '' }} + {% if post.created_at %} + + {% endif %} {{ l('浏览', 'Views') }} {{ post.view_count or 0 }} {{ l('点赞', 'Likes') }} {{ like_count or 0 }} {{ l('收藏', 'Bookmarks') }} {{ bookmark_count or 0 }} @@ -125,12 +166,14 @@ {% if comments %}
    {% for c in comments %} -
  • +
  • {{ (c.author_rel.username[0] if c.author_rel and c.author_rel.username else '?')|upper }}
    {{ c.author_rel.username if c.author_rel else l('已注销用户', 'Deleted user') }} - {{ c.created_at.strftime('%Y-%m-%d %H:%M') if c.created_at else '' }} + {% if c.created_at %} + + {% endif %}
    {{ c.content|markdown_html }}
    diff --git a/templates/forum/post_form.html b/templates/forum/post_form.html index 0d57325..e111ecb 100644 --- a/templates/forum/post_form.html +++ b/templates/forum/post_form.html @@ -3,6 +3,7 @@ + {{ l('发布帖子', 'Topic Editor') }} - {{ l('云价眼', 'VPS Price') }} diff --git a/templates/forum/profile.html b/templates/forum/profile.html index e799c9e..9ae7960 100644 --- a/templates/forum/profile.html +++ b/templates/forum/profile.html @@ -3,6 +3,7 @@ + {{ l('个人中心', 'Profile') }} - {{ l('论坛', 'Forum') }} diff --git a/templates/index.html b/templates/index.html index 6da92a5..d059eec 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,37 +3,28 @@ - {{ t.meta_title }} + {{ seo.title }} - - - - - - - - + + + + + + {% for hreflang, href in seo.alternate_links.items() %} + + {% endfor %} + + + + + + - - - + + + + + @@ -44,7 +35,7 @@
    - +

    {{ t.tagline }}

    @@ -193,12 +184,72 @@ - + {% if initial_plans_json %} + {% for plan in initial_plans_json %} + + {{ plan.provider or '-' }} + {{ plan.countries or '-' }} + {{ plan.name or '-' }} + {{ plan.vcpu if plan.vcpu is not none else '-' }} + {{ (plan.memory_gb ~ ' GB') if plan.memory_gb is not none else '-' }} + {{ (plan.storage_gb ~ ' GB') if plan.storage_gb is not none else '-' }} + {{ (plan.bandwidth_mbps ~ ' Mbps') if plan.bandwidth_mbps is not none else '-' }} + {{ plan.traffic or '-' }} + + {% if plan.price_cny is not none %} + ¥{{ '%.2f'|format(plan.price_cny) }} + {% elif plan.price_usd is not none %} + ${{ '%.2f'|format(plan.price_usd) }} + {% else %} + — + {% endif %} + + + {% if plan.official_url %} + {{ t.btn_visit }} + {% else %} + — + {% endif %} + + + {% endfor %} + {% else %} + + {{ t.empty_state }} + + {% endif %}
+

{{ t.disclaimer }}

+ +
+
+

{{ t.faq_title }}

+

{{ t.faq_intro }}

+
+
+ {% for item in faq_items %} +
+ {{ item.question }} +

{{ item.answer }}

+
+ {% endfor %} +
+
+ +
+

{{ t.cta_title }}

+

{{ t.cta_lede }}

+ +