哈哈
This commit is contained in:
122
app.py
122
app.py
@@ -1,10 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""云服务器价格对比 - Flask 应用"""
|
||||
import io
|
||||
import os
|
||||
from time import monotonic
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlencode
|
||||
from flask import Flask, render_template, jsonify, request, redirect, url_for, session, send_file
|
||||
from flask import Flask, render_template, jsonify, request, redirect, url_for, session, send_file, send_from_directory
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from sqlalchemy import text, func, or_
|
||||
from sqlalchemy.orm import joinedload
|
||||
@@ -496,7 +497,9 @@ def _humanize_time(dt, lang=None):
|
||||
return ""
|
||||
active_lang = lang or session.get("lang", "zh")
|
||||
if dt.tzinfo is None:
|
||||
now = datetime.utcnow()
|
||||
# 兼容历史“无时区”时间:按 UTC 解释后与当前 UTC 进行比较,避免 utcnow 弃用告警
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
now = datetime.now(timezone.utc)
|
||||
else:
|
||||
now = datetime.now(dt.tzinfo)
|
||||
delta = now - dt
|
||||
@@ -830,32 +833,57 @@ def _forum_redirect_with_msg(post_id, text_msg):
|
||||
# 首页多语言文案(中文 / English)
|
||||
I18N = {
|
||||
"zh": {
|
||||
"tagline": "云服务器价格一目了然",
|
||||
"filter_provider": "厂商",
|
||||
"filter_region": "区域",
|
||||
"meta_title": "全球 VPS 价格与配置对比 | 云价眼",
|
||||
"meta_description": "面向技术与采购团队的云服务器价格情报平台:统一对比主流厂商 VPS 月付价格、配置与区域,支持快速筛选并直达官方购买页。",
|
||||
"meta_keywords": "VPS价格对比,云服务器采购,云主机报价,云厂商比价,企业云成本,阿里云腾讯云DigitalOceanVultr",
|
||||
"og_title": "云价眼 | 全球 VPS 价格与配置决策台",
|
||||
"og_description": "为团队采购与技术选型提供可比价的云服务器数据视图,快速定位成本与性能平衡点。",
|
||||
"og_locale": "zh_CN",
|
||||
"schema_webapp_description": "面向团队采购与技术选型的 VPS 价格与配置对比平台。",
|
||||
"schema_table_about": "云价眼 - 全球 VPS 价格与配置决策台",
|
||||
"schema_table_name": "VPS 价格与配置对比表",
|
||||
"schema_table_description": "主流云厂商 VPS 方案的配置、区域与月付价格数据",
|
||||
"tagline": "面向团队采购的云服务器价格情报",
|
||||
"hero_kicker": "企业云资源采购情报",
|
||||
"hero_title": "全球 VPS 价格与配置决策台",
|
||||
"hero_lede": "聚合主流云厂商公开报价,统一月付口径与配置维度,帮助技术与采购团队更快完成方案筛选与预算评估。",
|
||||
"hero_trust_1": "主流云厂商持续收录",
|
||||
"hero_trust_2": "统一月付与配置口径",
|
||||
"hero_trust_3": "直达官方购买与文档",
|
||||
"metric_total_plans": "可比较方案",
|
||||
"metric_providers": "覆盖厂商",
|
||||
"metric_regions": "覆盖区域",
|
||||
"metric_lowest": "筛选后最低月价",
|
||||
"filters_title": "采购筛选控制台",
|
||||
"filters_subtitle": "按厂商、区域、资源规格与预算快速收敛候选方案。",
|
||||
"table_caption": "价格与配置根据筛选条件实时刷新,用于初步比选与预算评估。",
|
||||
"filter_provider": "供应商",
|
||||
"filter_region": "区域市场",
|
||||
"filter_memory": "内存 ≥",
|
||||
"filter_price": "价格区间",
|
||||
"filter_currency": "货币",
|
||||
"search_placeholder": "搜索厂商、配置...",
|
||||
"filter_currency": "计价货币",
|
||||
"search_placeholder": "搜索供应商、方案或区域...",
|
||||
"all": "全部",
|
||||
"unlimited": "不限",
|
||||
"btn_reset": "重置筛选",
|
||||
"th_provider": "厂商",
|
||||
"th_country": "国家",
|
||||
"th_config": "配置",
|
||||
"btn_reset": "清空筛选",
|
||||
"btn_visit": "查看官网",
|
||||
"th_provider": "供应商",
|
||||
"th_country": "区域",
|
||||
"th_config": "实例规格",
|
||||
"th_vcpu": "vCPU",
|
||||
"th_memory": "内存",
|
||||
"th_storage": "存储",
|
||||
"th_bandwidth": "带宽",
|
||||
"th_traffic": "流量",
|
||||
"th_price": "月付价格",
|
||||
"th_action": "操作",
|
||||
"disclaimer": "* 价格仅供参考,以各厂商官网为准。部分为按量/包年折算月价。",
|
||||
"footer_note": "数据仅供参考 · 请以云厂商官网实时报价为准",
|
||||
"th_price": "月付参考价",
|
||||
"th_action": "官方链接",
|
||||
"disclaimer": "* 数据来自公开页面与规则换算,可能存在时差或促销偏差;下单前请以厂商官网实时价格与条款为准。",
|
||||
"footer_note": "仅作采购调研参考 · 请以各云厂商官网实时价格为准",
|
||||
"contact_label": "联系我们",
|
||||
"empty_state": "未找到匹配的方案",
|
||||
"load_error": "数据加载失败,请刷新页面重试",
|
||||
"search_label": "搜索",
|
||||
"search_label": "关键词检索",
|
||||
"result_count_pattern": "当前筛选:{visible} / {total} 个方案",
|
||||
"price_under50": "< ¥50",
|
||||
"price_50_100": "¥50-100",
|
||||
"price_100_300": "¥100-300",
|
||||
@@ -865,32 +893,57 @@ I18N = {
|
||||
"usd": "美元 ($)",
|
||||
},
|
||||
"en": {
|
||||
"tagline": "VPS & cloud server prices at a glance",
|
||||
"meta_title": "Global VPS Pricing & Configuration Comparison | VPS Price",
|
||||
"meta_description": "Pricing intelligence for engineering and procurement teams: compare VPS monthly costs, specs, and regions across major providers with normalized criteria.",
|
||||
"meta_keywords": "VPS pricing comparison,cloud server procurement,provider pricing benchmark,cloud cost planning,infrastructure buying",
|
||||
"og_title": "VPS Price | Global VPS Pricing Decision Console",
|
||||
"og_description": "A procurement-ready view of VPS pricing and specs across major providers for faster, more confident infrastructure decisions.",
|
||||
"og_locale": "en_US",
|
||||
"schema_webapp_description": "A pricing and configuration comparison platform for VPS procurement and technical planning.",
|
||||
"schema_table_about": "VPS Price - Global VPS Pricing Decision Console",
|
||||
"schema_table_name": "VPS Pricing and Configuration Table",
|
||||
"schema_table_description": "Comparable monthly pricing, specs, and region data across mainstream VPS providers",
|
||||
"tagline": "Cloud pricing intelligence for engineering and procurement teams",
|
||||
"hero_kicker": "Enterprise Infrastructure Intelligence",
|
||||
"hero_title": "Global VPS Pricing Decision Console",
|
||||
"hero_lede": "Aggregate public VPS offers, normalize monthly pricing and specs, and help engineering and procurement teams shortlist options faster.",
|
||||
"hero_trust_1": "Major providers continuously tracked",
|
||||
"hero_trust_2": "Normalized monthly pricing and specs",
|
||||
"hero_trust_3": "Direct links to official purchase pages",
|
||||
"metric_total_plans": "Comparable Plans",
|
||||
"metric_providers": "Providers Covered",
|
||||
"metric_regions": "Regions Covered",
|
||||
"metric_lowest": "Lowest Monthly Price",
|
||||
"filters_title": "Procurement Filter Console",
|
||||
"filters_subtitle": "Narrow candidates by provider, region, resource profile, and budget range.",
|
||||
"table_caption": "Pricing and specs refresh in real time based on active filters for quicker shortlist decisions.",
|
||||
"filter_provider": "Provider",
|
||||
"filter_region": "Region",
|
||||
"filter_memory": "Memory ≥",
|
||||
"filter_price": "Price range",
|
||||
"filter_currency": "Currency",
|
||||
"search_placeholder": "Search provider, config...",
|
||||
"search_placeholder": "Search provider, plan, or region...",
|
||||
"all": "All",
|
||||
"unlimited": "Any",
|
||||
"btn_reset": "Reset",
|
||||
"btn_reset": "Clear filters",
|
||||
"btn_visit": "Visit Site",
|
||||
"th_provider": "Provider",
|
||||
"th_country": "Country",
|
||||
"th_config": "Config",
|
||||
"th_country": "Region",
|
||||
"th_config": "Plan Spec",
|
||||
"th_vcpu": "vCPU",
|
||||
"th_memory": "Memory",
|
||||
"th_storage": "Storage",
|
||||
"th_bandwidth": "Bandwidth",
|
||||
"th_traffic": "Traffic",
|
||||
"th_price": "Monthly",
|
||||
"th_action": "Action",
|
||||
"disclaimer": "* Prices are indicative. See provider sites for current rates.",
|
||||
"footer_note": "Data for reference only. Check provider sites for latest pricing.",
|
||||
"th_price": "Monthly Price",
|
||||
"th_action": "Official Link",
|
||||
"disclaimer": "* Data is compiled from public sources and normalization rules. Final billing terms and live pricing are determined by each provider.",
|
||||
"footer_note": "For research and shortlisting only. Always verify latest pricing on official provider websites.",
|
||||
"contact_label": "Contact",
|
||||
"empty_state": "No matching plans found",
|
||||
"load_error": "Failed to load data. Please refresh.",
|
||||
"search_label": "Search",
|
||||
"search_label": "Keyword Search",
|
||||
"result_count_pattern": "Showing {visible} of {total} plans",
|
||||
"price_under50": "< 50",
|
||||
"price_50_100": "50-100",
|
||||
"price_100_300": "100-300",
|
||||
@@ -939,6 +992,23 @@ def index():
|
||||
)
|
||||
|
||||
|
||||
@app.route("/assets/<path:filename>")
|
||||
def legacy_assets(filename):
|
||||
"""
|
||||
兼容历史内容中的 /assets/* 链接:
|
||||
- 若 static/assets 下存在目标文件则直接返回
|
||||
- 否则回退到站点标识图,避免前端出现 404 噪音
|
||||
"""
|
||||
assets_dir = os.path.join(app.static_folder or "", "assets")
|
||||
candidate = os.path.normpath(os.path.join(assets_dir, filename))
|
||||
assets_dir_abs = os.path.abspath(assets_dir)
|
||||
candidate_abs = os.path.abspath(candidate)
|
||||
if candidate_abs.startswith(assets_dir_abs + os.sep) and os.path.isfile(candidate_abs):
|
||||
rel_path = os.path.relpath(candidate_abs, assets_dir_abs)
|
||||
return send_from_directory(assets_dir_abs, rel_path)
|
||||
return redirect(url_for("static", filename="img/site-logo-mark.svg"), code=302)
|
||||
|
||||
|
||||
@app.route("/api/plans")
|
||||
def api_plans():
|
||||
now_ts = monotonic()
|
||||
|
||||
@@ -1217,6 +1217,154 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Commercial Refresh (2026-02)
|
||||
========================================================================== */
|
||||
|
||||
.forum-page {
|
||||
background:
|
||||
radial-gradient(880px 330px at 6% -12%, rgba(3, 105, 161, 0.07), transparent 64%),
|
||||
radial-gradient(760px 300px at 92% -20%, rgba(15, 23, 42, 0.08), transparent 68%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.forum-header {
|
||||
backdrop-filter: blur(14px);
|
||||
background: rgba(248, 250, 252, 0.88);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.26);
|
||||
}
|
||||
|
||||
.forum-header::before {
|
||||
height: 1px;
|
||||
opacity: 1;
|
||||
background: linear-gradient(90deg, rgba(3, 105, 161, 0.2), rgba(15, 23, 42, 0.1), rgba(3, 105, 161, 0.2));
|
||||
}
|
||||
|
||||
.forum-primary-nav a,
|
||||
.forum-link {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.forum-user-chip {
|
||||
border-color: rgba(148, 163, 184, 0.38);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.forum-shell {
|
||||
padding-top: 1.2rem;
|
||||
}
|
||||
|
||||
.topic-stream,
|
||||
.side-card,
|
||||
.topic-post-card,
|
||||
.comment-form-card {
|
||||
border: 1px solid rgba(148, 163, 184, 0.27);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(15, 23, 42, 0.08),
|
||||
0 16px 32px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.topic-head {
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.92), rgba(248, 250, 252, 0.58));
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.topic-row:hover {
|
||||
background: rgba(241, 245, 249, 0.82);
|
||||
}
|
||||
|
||||
.topic-avatar {
|
||||
background: linear-gradient(145deg, rgba(3, 105, 161, 0.13), rgba(15, 23, 42, 0.08));
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.topic-title {
|
||||
color: #020617;
|
||||
}
|
||||
|
||||
.topic-title:hover {
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.topic-meta,
|
||||
.topic-stat,
|
||||
.topic-result,
|
||||
.side-empty {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.side-card h3 {
|
||||
color: #0f172a;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.forum-btn-primary {
|
||||
background: #0f172a;
|
||||
border: 1px solid #0f172a;
|
||||
}
|
||||
|
||||
.forum-btn-primary:hover {
|
||||
background: #020617;
|
||||
border-color: #020617;
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.2);
|
||||
}
|
||||
|
||||
.forum-btn-muted {
|
||||
border-color: rgba(148, 163, 184, 0.45);
|
||||
}
|
||||
|
||||
.forum-btn-muted:hover,
|
||||
.forum-btn-muted.active {
|
||||
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);
|
||||
}
|
||||
|
||||
.page-link.active {
|
||||
border-color: #0f172a;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.post-form input[type="text"],
|
||||
.post-form input[type="password"],
|
||||
.post-form textarea,
|
||||
.post-form select,
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
border-color: rgba(148, 163, 184, 0.48);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.post-form input[type="text"]:focus,
|
||||
.post-form input[type="password"]:focus,
|
||||
.post-form textarea:focus,
|
||||
.post-form select:focus,
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus {
|
||||
border-color: rgba(3, 105, 161, 0.6);
|
||||
box-shadow: 0 0 0 3px rgba(3, 105, 161, 0.12);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.forum-shell {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.topic-stream,
|
||||
.side-card,
|
||||
.topic-post-card,
|
||||
.comment-form-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
transition: none !important;
|
||||
|
||||
@@ -2505,3 +2505,463 @@ html {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Commercial Refresh (2026-02)
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
--font-sans: "Plus Jakarta Sans", "Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.homepage-body {
|
||||
background:
|
||||
radial-gradient(1100px 460px at 12% -12%, rgba(3, 105, 161, 0.08), transparent 62%),
|
||||
radial-gradient(1000px 420px at 88% -18%, rgba(15, 23, 42, 0.08), transparent 64%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.homepage-body .header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 120;
|
||||
backdrop-filter: blur(14px);
|
||||
background: rgba(248, 250, 252, 0.88);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.26);
|
||||
}
|
||||
|
||||
.homepage-body .header::before {
|
||||
height: 1px;
|
||||
opacity: 1;
|
||||
background: linear-gradient(90deg, rgba(3, 105, 161, 0.22), rgba(15, 23, 42, 0.1), rgba(3, 105, 161, 0.22));
|
||||
}
|
||||
|
||||
.homepage-body .header-brand {
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.homepage-body .site-logo {
|
||||
width: clamp(172px, 22vw, 232px);
|
||||
}
|
||||
|
||||
.homepage-body .tagline {
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.homepage-body .header-nav a {
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.homepage-body .header-nav a:hover {
|
||||
color: #0f172a;
|
||||
background: rgba(3, 105, 161, 0.1);
|
||||
}
|
||||
|
||||
.homepage-main {
|
||||
padding-top: 1.65rem;
|
||||
padding-bottom: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.15rem;
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
padding: 1.2rem;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(248, 250, 252, 0.95), rgba(241, 245, 249, 0.95));
|
||||
box-shadow:
|
||||
0 1px 2px rgba(15, 23, 42, 0.08),
|
||||
0 18px 36px rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.hero-kicker {
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #0f172a;
|
||||
padding: 0.34rem 0.62rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(3, 105, 161, 0.1);
|
||||
border: 1px solid rgba(3, 105, 161, 0.2);
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.6rem, 3.1vw, 2.2rem);
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.02em;
|
||||
color: #020617;
|
||||
}
|
||||
|
||||
.hero-lede {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 0.98rem;
|
||||
max-width: 66ch;
|
||||
}
|
||||
|
||||
.hero-trust-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.hero-trust-row span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.76rem;
|
||||
color: #334155;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.36);
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.hero-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border-radius: 12px;
|
||||
padding: 0.82rem 0.85rem;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
margin: 0.28rem 0 0;
|
||||
font-size: 1.32rem;
|
||||
line-height: 1;
|
||||
color: #020617;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.homepage-body .filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.95rem;
|
||||
padding: 1rem;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(15, 23, 42, 0.08),
|
||||
0 14px 28px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.homepage-body .filters:hover {
|
||||
border-color: rgba(51, 65, 85, 0.28);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(15, 23, 42, 0.08),
|
||||
0 20px 34px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.filters-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters-title {
|
||||
margin: 0;
|
||||
font-size: 1.02rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.filters-subtitle {
|
||||
margin: 0.28rem 0 0;
|
||||
color: #64748b;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.homepage-body .result-count {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
background: rgba(241, 245, 249, 0.9);
|
||||
padding: 0.38rem 0.68rem;
|
||||
}
|
||||
|
||||
.filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 0.66rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.homepage-body .filter-group {
|
||||
grid-column: span 2;
|
||||
gap: 0.32rem;
|
||||
}
|
||||
|
||||
.homepage-body .filter-group-search {
|
||||
grid-column: span 3;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
grid-column: span 1;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.homepage-body .filter-group label {
|
||||
text-transform: none;
|
||||
letter-spacing: 0.01em;
|
||||
font-size: 0.74rem;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.homepage-body .filter-group select,
|
||||
.homepage-body .filter-group-search input {
|
||||
min-height: 40px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.45);
|
||||
box-shadow: inset 0 1px 1px rgba(15, 23, 42, 0.02);
|
||||
}
|
||||
|
||||
.homepage-body .filter-group select:hover,
|
||||
.homepage-body .filter-group-search input:hover {
|
||||
border-color: rgba(3, 105, 161, 0.45);
|
||||
}
|
||||
|
||||
.homepage-body .btn-reset {
|
||||
margin-left: 0;
|
||||
min-height: 40px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #0f172a;
|
||||
background: #0f172a;
|
||||
color: #f8fafc;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
padding: 0.5rem 0.95rem;
|
||||
}
|
||||
|
||||
.homepage-body .btn-reset:hover {
|
||||
border-color: #020617;
|
||||
background: #020617;
|
||||
color: #f8fafc;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.2);
|
||||
}
|
||||
|
||||
.homepage-body .table-wrap {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(15, 23, 42, 0.08),
|
||||
0 16px 28px rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
.homepage-body .table-wrap::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-wrap-head {
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.24);
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.9), rgba(248, 250, 252, 0.55));
|
||||
}
|
||||
|
||||
.table-caption {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.homepage-body .price-table {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.homepage-body .price-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.homepage-body .price-table tbody tr {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.homepage-body .price-table tbody tr:hover {
|
||||
background: rgba(241, 245, 249, 0.86);
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.homepage-body .price-table td.col-price {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.homepage-body .price-table th.col-link,
|
||||
.homepage-body .price-table td.col-link {
|
||||
display: table-cell;
|
||||
}
|
||||
|
||||
.homepage-body .price-table td.col-link {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.homepage-body .price-table .col-link a {
|
||||
border-radius: 9px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.homepage-body .disclaimer {
|
||||
margin-top: 0;
|
||||
color: #64748b;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.homepage-body .footer {
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.3);
|
||||
background: rgba(248, 250, 252, 0.88);
|
||||
}
|
||||
|
||||
.floating-contact-btn {
|
||||
right: 1.3rem;
|
||||
bottom: 1.3rem;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.14);
|
||||
background: linear-gradient(145deg, #0f172a, #1e293b);
|
||||
box-shadow: 0 12px 22px rgba(15, 23, 42, 0.24);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.floating-contact-btn:hover {
|
||||
background: linear-gradient(145deg, #020617, #0f172a);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.26);
|
||||
}
|
||||
|
||||
.floating-contact-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.floating-contact-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.hero-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero-metrics {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.filter-grid {
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.homepage-body .filter-group {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.homepage-body .filter-group-search {
|
||||
grid-column: span 4;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
grid-column: span 2;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.homepage-main {
|
||||
padding-top: 1.05rem;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
padding: 0.9rem;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 1.52rem;
|
||||
}
|
||||
|
||||
.hero-lede {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.hero-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.filter-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.homepage-body .filter-group,
|
||||
.homepage-body .filter-group-search,
|
||||
.filter-actions {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.homepage-body .btn-reset {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.homepage-body .price-table th,
|
||||
.homepage-body .price-table td {
|
||||
padding: 0.54rem 0.62rem;
|
||||
}
|
||||
|
||||
.floating-contact-btn {
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
// ==================== 全局变量 ====================
|
||||
var allPlans = [];
|
||||
var isEnglish = window.LANG === 'en';
|
||||
|
||||
// 排序状态
|
||||
var currentSort = {
|
||||
@@ -71,6 +72,7 @@
|
||||
// 优先使用服务端直出的数据,首屏无需再请求 /api/plans
|
||||
if (window.__INITIAL_PLANS__ && Array.isArray(window.__INITIAL_PLANS__) && window.__INITIAL_PLANS__.length >= 0) {
|
||||
allPlans = window.__INITIAL_PLANS__;
|
||||
updateSummaryMetrics(allPlans);
|
||||
populateFilters();
|
||||
renderTable();
|
||||
return;
|
||||
@@ -82,6 +84,7 @@
|
||||
})
|
||||
.then(function(data) {
|
||||
allPlans = data;
|
||||
updateSummaryMetrics(allPlans);
|
||||
populateFilters();
|
||||
renderTable();
|
||||
})
|
||||
@@ -218,6 +221,8 @@
|
||||
function renderTable() {
|
||||
var filtered = filterPlans(allPlans);
|
||||
var sorted = sortPlans(filtered);
|
||||
updateResultCount(sorted.length, allPlans.length);
|
||||
updateLowestMetric(sorted);
|
||||
|
||||
var tbody = document.getElementById('table-body');
|
||||
tbody.innerHTML = '';
|
||||
@@ -342,6 +347,59 @@
|
||||
tbody.innerHTML = '<tr><td colspan="10" style="text-align: center; padding: 2rem; color: #EF4444;">' + message + '</td></tr>';
|
||||
}
|
||||
|
||||
function setText(id, text) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
function formatCount(value) {
|
||||
if (typeof value !== 'number' || isNaN(value)) return '--';
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
function updateSummaryMetrics(plans) {
|
||||
var providerSet = new Set();
|
||||
var regionSet = new Set();
|
||||
|
||||
plans.forEach(function(plan) {
|
||||
if (plan.provider) providerSet.add(plan.provider);
|
||||
if (plan.countries) regionSet.add(plan.countries);
|
||||
});
|
||||
|
||||
setText('metric-total-plans', formatCount(plans.length));
|
||||
setText('metric-providers', formatCount(providerSet.size));
|
||||
setText('metric-regions', formatCount(regionSet.size));
|
||||
}
|
||||
|
||||
function updateLowestMetric(plans) {
|
||||
var lowest = null;
|
||||
var symbol = filters.currency === 'USD' ? '$' : '¥';
|
||||
|
||||
plans.forEach(function(plan) {
|
||||
var currentPrice = getPriceValue(plan, filters.currency);
|
||||
if (!currentPrice) return;
|
||||
symbol = currentPrice.symbol;
|
||||
if (lowest == null || currentPrice.value < lowest) {
|
||||
lowest = currentPrice.value;
|
||||
}
|
||||
});
|
||||
|
||||
if (lowest == null) {
|
||||
setText('metric-lowest', '--');
|
||||
return;
|
||||
}
|
||||
setText('metric-lowest', symbol + lowest.toFixed(2));
|
||||
}
|
||||
|
||||
function updateResultCount(visibleCount, totalCount) {
|
||||
var pattern = (window.I18N_JS && window.I18N_JS.result_count_pattern)
|
||||
|| (isEnglish ? 'Showing {visible} of {total} plans' : '筛选结果 {visible} / {total}');
|
||||
var label = pattern
|
||||
.replace('{visible}', formatCount(visibleCount))
|
||||
.replace('{total}', formatCount(totalCount));
|
||||
setText('result-count', label);
|
||||
}
|
||||
|
||||
// ==================== 启动 ====================
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
@@ -3,24 +3,24 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ site_name }} - Global VPS Price Comparison</title>
|
||||
<title>{{ t.meta_title }}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='img/site-logo-mark.svg') }}">
|
||||
<meta name="description" content="云服务器 VPS 价格对比表:阿里云、腾讯云、华为云、DigitalOcean、Vultr、Linode、AWS Lightsail 等厂商月付价格与配置对比,支持按区域、内存筛选,一键跳转官网。">
|
||||
<meta name="keywords" content="云服务器价格,VPS价格对比,阿里云价格,腾讯云价格,DigitalOcean,Vultr,Linode,云主机月付">
|
||||
<meta name="description" content="{{ t.meta_description }}">
|
||||
<meta name="keywords" content="{{ t.meta_keywords }}">
|
||||
<link rel="canonical" href="{{ site_url }}/">
|
||||
<!-- Open Graph / 社交分享 -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{ site_url }}/">
|
||||
<meta property="og:title" content="{{ site_name }} - 云服务器价格对比">
|
||||
<meta property="og:description" content="云服务器 VPS 月付价格对比:阿里云、腾讯云、DigitalOcean、Vultr 等,支持筛选与官网跳转。">
|
||||
<meta property="og:locale" content="zh_CN">
|
||||
<meta property="og:title" content="{{ t.og_title }}">
|
||||
<meta property="og:description" content="{{ t.og_description }}">
|
||||
<meta property="og:locale" content="{{ t.og_locale }}">
|
||||
<!-- JSON-LD 结构化数据,便于搜索引擎理解 -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"name": "{{ site_name }}",
|
||||
"description": "云服务器 VPS 月付价格对比表,支持多厂商筛选与官网跳转。",
|
||||
"description": "{{ t.schema_webapp_description }}",
|
||||
"url": "{{ site_url }}",
|
||||
"applicationCategory": "UtilitiesApplication"
|
||||
}
|
||||
@@ -29,17 +29,17 @@
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Table",
|
||||
"about": "云价眼 - 云服务器 VPS 价格对比",
|
||||
"name": "VPS 价格对比表",
|
||||
"description": "各云厂商 VPS 方案配置与月付价格"
|
||||
"about": "{{ t.schema_table_about }}",
|
||||
"name": "{{ t.schema_table_name }}",
|
||||
"description": "{{ t.schema_table_description }}"
|
||||
}
|
||||
</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Noto+Sans+SC:wght@400;500;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body data-lang="{{ lang }}">
|
||||
<body data-lang="{{ lang }}" class="homepage-body">
|
||||
<header class="header">
|
||||
<div class="header-inner">
|
||||
<div class="header-brand">
|
||||
@@ -76,53 +76,95 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<main class="main homepage-main">
|
||||
<section class="hero-panel">
|
||||
<div class="hero-copy">
|
||||
<p class="hero-kicker">{{ t.hero_kicker }}</p>
|
||||
<h1 class="hero-title">{{ t.hero_title }}</h1>
|
||||
<p class="hero-lede">{{ t.hero_lede }}</p>
|
||||
<div class="hero-trust-row">
|
||||
<span>{{ t.hero_trust_1 }}</span>
|
||||
<span>{{ t.hero_trust_2 }}</span>
|
||||
<span>{{ t.hero_trust_3 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-metrics">
|
||||
<article class="metric-card">
|
||||
<p class="metric-label">{{ t.metric_total_plans }}</p>
|
||||
<p class="metric-value" id="metric-total-plans">--</p>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<p class="metric-label">{{ t.metric_providers }}</p>
|
||||
<p class="metric-value" id="metric-providers">--</p>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<p class="metric-label">{{ t.metric_regions }}</p>
|
||||
<p class="metric-value" id="metric-regions">--</p>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<p class="metric-label">{{ t.metric_lowest }}</p>
|
||||
<p class="metric-value" id="metric-lowest">--</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="filters">
|
||||
<div class="filter-group">
|
||||
<label for="filter-provider">{{ t.filter_provider }}</label>
|
||||
<select id="filter-provider">
|
||||
<option value="">{{ t.all }}</option>
|
||||
</select>
|
||||
<div class="filters-head">
|
||||
<div>
|
||||
<h2 class="filters-title">{{ t.filters_title }}</h2>
|
||||
<p class="filters-subtitle">{{ t.filters_subtitle }}</p>
|
||||
</div>
|
||||
<p class="result-count" id="result-count">--</p>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-region">{{ t.filter_region }}</label>
|
||||
<select id="filter-region">
|
||||
<option value="">{{ t.all }}</option>
|
||||
</select>
|
||||
<div class="filter-grid">
|
||||
<div class="filter-group">
|
||||
<label for="filter-provider">{{ t.filter_provider }}</label>
|
||||
<select id="filter-provider">
|
||||
<option value="">{{ t.all }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-region">{{ t.filter_region }}</label>
|
||||
<select id="filter-region">
|
||||
<option value="">{{ t.all }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-memory">{{ t.filter_memory }}</label>
|
||||
<select id="filter-memory">
|
||||
<option value="0">{{ t.unlimited }}</option>
|
||||
<option value="1">1 GB</option>
|
||||
<option value="2">2 GB</option>
|
||||
<option value="4">4 GB</option>
|
||||
<option value="8">8 GB</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-price">{{ t.filter_price }}</label>
|
||||
<select id="filter-price">
|
||||
<option value="0">{{ t.unlimited }}</option>
|
||||
<option value="0-50">{{ t.price_under50 }}</option>
|
||||
<option value="50-100">{{ t.price_50_100 }}</option>
|
||||
<option value="100-300">{{ t.price_100_300 }}</option>
|
||||
<option value="300-500">{{ t.price_300_500 }}</option>
|
||||
<option value="500-99999">{{ t.price_over500 }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-currency">{{ t.filter_currency }}</label>
|
||||
<select id="filter-currency">
|
||||
<option value="CNY">{{ t.cny }}</option>
|
||||
<option value="USD">{{ t.usd }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group filter-group-search">
|
||||
<label for="search-input">{{ t.search_label }}</label>
|
||||
<input type="text" id="search-input" placeholder="{{ t.search_placeholder }}" />
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<button type="button" class="btn-reset" id="btn-reset">{{ t.btn_reset }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-memory">{{ t.filter_memory }}</label>
|
||||
<select id="filter-memory">
|
||||
<option value="0">{{ t.unlimited }}</option>
|
||||
<option value="1">1 GB</option>
|
||||
<option value="2">2 GB</option>
|
||||
<option value="4">4 GB</option>
|
||||
<option value="8">8 GB</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-price">{{ t.filter_price }}</label>
|
||||
<select id="filter-price">
|
||||
<option value="0">{{ t.unlimited }}</option>
|
||||
<option value="0-50">{{ t.price_under50 }}</option>
|
||||
<option value="50-100">{{ t.price_50_100 }}</option>
|
||||
<option value="100-300">{{ t.price_100_300 }}</option>
|
||||
<option value="300-500">{{ t.price_300_500 }}</option>
|
||||
<option value="500-99999">{{ t.price_over500 }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-currency">{{ t.filter_currency }}</label>
|
||||
<select id="filter-currency">
|
||||
<option value="CNY">{{ t.cny }}</option>
|
||||
<option value="USD">{{ t.usd }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group filter-group-search">
|
||||
<label for="search-input">{{ t.search_label }}</label>
|
||||
<input type="text" id="search-input" placeholder="{{ t.search_placeholder }}" />
|
||||
</div>
|
||||
<button type="button" class="btn-reset" id="btn-reset">{{ t.btn_reset }}</button>
|
||||
</section>
|
||||
|
||||
<!-- 广告位 2:表格上方。可放置矩形或横条广告 -->
|
||||
@@ -132,6 +174,9 @@
|
||||
|
||||
<!-- 服务器列表 -->
|
||||
<section class="table-wrap">
|
||||
<div class="table-wrap-head">
|
||||
<p class="table-caption">{{ t.table_caption }}</p>
|
||||
</div>
|
||||
<table class="price-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -185,7 +230,8 @@
|
||||
window.I18N_JS = {
|
||||
empty_state: {{ t.empty_state|tojson }},
|
||||
load_error: {{ t.load_error|tojson }},
|
||||
btn_visit: {{ ('访问' if lang == 'zh' else 'Visit')|tojson }}
|
||||
btn_visit: {{ t.btn_visit|tojson }},
|
||||
result_count_pattern: {{ t.result_count_pattern|tojson }}
|
||||
};
|
||||
// 首屏直出数据,避免等待 /api/plans 再渲染表格,加快首屏
|
||||
window.__INITIAL_PLANS__ = {{ initial_plans_json|tojson }};
|
||||
|
||||
Reference in New Issue
Block a user