哈哈
This commit is contained in:
620
app.py
620
app.py
@@ -1,10 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""云服务器价格对比 - Flask 应用"""
|
||||
import io
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlencode
|
||||
from flask import Flask, render_template, jsonify, request, redirect, url_for, session, send_file
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import text, func
|
||||
from config import Config
|
||||
from extensions import db
|
||||
from openpyxl import Workbook
|
||||
@@ -16,7 +17,7 @@ app.config.from_object(Config)
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||||
db.init_app(app)
|
||||
|
||||
from models import VPSPlan, Provider # noqa: E402
|
||||
from models import VPSPlan, Provider, PriceHistory, User, ForumPost, ForumComment # noqa: E402
|
||||
|
||||
|
||||
def _ensure_mysql_columns():
|
||||
@@ -51,10 +52,37 @@ def _ensure_mysql_columns():
|
||||
pass # 表不存在或非 MySQL 时忽略
|
||||
|
||||
|
||||
def _ensure_price_history_baseline():
|
||||
"""为历史数据补首条价格快照,便于后续计算涨跌。"""
|
||||
try:
|
||||
missing = (
|
||||
db.session.query(VPSPlan)
|
||||
.outerjoin(PriceHistory, PriceHistory.plan_id == VPSPlan.id)
|
||||
.filter(PriceHistory.id.is_(None))
|
||||
.all()
|
||||
)
|
||||
if not missing:
|
||||
return
|
||||
for p in missing:
|
||||
if p.price_cny is None and p.price_usd is None:
|
||||
continue
|
||||
db.session.add(PriceHistory(
|
||||
plan_id=p.id,
|
||||
price_cny=p.price_cny,
|
||||
price_usd=p.price_usd,
|
||||
currency=(p.currency or "CNY"),
|
||||
source="bootstrap",
|
||||
))
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
|
||||
|
||||
# 启动时自动创建表(若不存在),并为已有表补列
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
_ensure_mysql_columns()
|
||||
_ensure_price_history_baseline()
|
||||
|
||||
ADMIN_PASSWORD = app.config["ADMIN_PASSWORD"]
|
||||
SITE_URL = app.config["SITE_URL"]
|
||||
@@ -66,6 +94,133 @@ COUNTRY_TAGS = [
|
||||
"德国", "英国", "法国", "荷兰", "加拿大", "澳大利亚", "印度", "其他",
|
||||
]
|
||||
|
||||
PRICE_SOURCE_LABELS = {
|
||||
"manual": "手工编辑",
|
||||
"import": "Excel 导入",
|
||||
"bootstrap": "基线",
|
||||
}
|
||||
|
||||
|
||||
def _get_current_user():
|
||||
user_id = session.get("user_id")
|
||||
if not user_id:
|
||||
return None
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
session.pop("user_id", None)
|
||||
return user
|
||||
|
||||
|
||||
def _is_valid_username(username):
|
||||
if not username:
|
||||
return False
|
||||
if len(username) < 3 or len(username) > 20:
|
||||
return False
|
||||
return all(ch.isalnum() or ch == "_" for ch in username)
|
||||
|
||||
|
||||
def _safe_next_url(default_endpoint):
|
||||
nxt = (request.values.get("next") or "").strip()
|
||||
if nxt.startswith("/") and not nxt.startswith("//"):
|
||||
return nxt
|
||||
return url_for(default_endpoint)
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_global_user():
|
||||
return {
|
||||
"current_user": _get_current_user(),
|
||||
"admin_logged_in": bool(session.get("admin_logged_in")),
|
||||
}
|
||||
|
||||
|
||||
def _currency_symbol(currency):
|
||||
return "¥" if (currency or "CNY").upper() == "CNY" else "$"
|
||||
|
||||
|
||||
def _format_money(currency, value):
|
||||
return "{}{:.2f}".format(_currency_symbol(currency), float(value))
|
||||
|
||||
|
||||
def _format_history_time(dt):
|
||||
return dt.strftime("%Y-%m-%d %H:%M") if dt else ""
|
||||
|
||||
|
||||
def _pick_price_pair(latest, previous=None):
|
||||
if previous is None:
|
||||
if latest.price_cny is not None:
|
||||
return "CNY", float(latest.price_cny), None
|
||||
if latest.price_usd is not None:
|
||||
return "USD", float(latest.price_usd), None
|
||||
return None, None, None
|
||||
if latest.price_cny is not None and previous.price_cny is not None:
|
||||
return "CNY", float(latest.price_cny), float(previous.price_cny)
|
||||
if latest.price_usd is not None and previous.price_usd is not None:
|
||||
return "USD", float(latest.price_usd), float(previous.price_usd)
|
||||
return None, None, None
|
||||
|
||||
|
||||
def _build_price_trend(latest, previous=None):
|
||||
currency, current_value, previous_value = _pick_price_pair(latest, previous)
|
||||
if currency is None or current_value is None:
|
||||
return None
|
||||
source = PRICE_SOURCE_LABELS.get(latest.source or "", latest.source or "未知来源")
|
||||
meta = "当前 {} · {} · {}".format(
|
||||
_format_money(currency, current_value),
|
||||
_format_history_time(latest.captured_at),
|
||||
source,
|
||||
)
|
||||
if previous_value is None:
|
||||
return {
|
||||
"direction": "new",
|
||||
"delta_text": "首次记录",
|
||||
"meta_text": meta,
|
||||
}
|
||||
diff = current_value - previous_value
|
||||
if abs(diff) < 1e-9:
|
||||
return {
|
||||
"direction": "flat",
|
||||
"delta_text": "→ 持平",
|
||||
"meta_text": meta,
|
||||
}
|
||||
direction = "up" if diff > 0 else "down"
|
||||
arrow = "↑" if diff > 0 else "↓"
|
||||
sign = "+" if diff > 0 else "-"
|
||||
delta_text = "{} {}{}{:,.2f}".format(arrow, sign, _currency_symbol(currency), abs(diff))
|
||||
if abs(previous_value) > 1e-9:
|
||||
pct = diff / previous_value * 100
|
||||
delta_text += " ({:+.2f}%)".format(pct)
|
||||
return {
|
||||
"direction": direction,
|
||||
"delta_text": delta_text,
|
||||
"meta_text": meta,
|
||||
}
|
||||
|
||||
|
||||
def _build_plan_trend_map(plans):
|
||||
plan_ids = [p.id for p in plans if p.id is not None]
|
||||
if not plan_ids:
|
||||
return {}
|
||||
rows = (
|
||||
PriceHistory.query
|
||||
.filter(PriceHistory.plan_id.in_(plan_ids))
|
||||
.order_by(PriceHistory.plan_id.asc(), PriceHistory.captured_at.desc(), PriceHistory.id.desc())
|
||||
.all()
|
||||
)
|
||||
grouped = {}
|
||||
for row in rows:
|
||||
bucket = grouped.setdefault(row.plan_id, [])
|
||||
if len(bucket) < 2:
|
||||
bucket.append(row)
|
||||
result = {}
|
||||
for plan_id, bucket in grouped.items():
|
||||
latest = bucket[0] if bucket else None
|
||||
previous = bucket[1] if len(bucket) > 1 else None
|
||||
trend = _build_price_trend(latest, previous) if latest else None
|
||||
if trend:
|
||||
result[plan_id] = trend
|
||||
return result
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
from functools import wraps
|
||||
@@ -77,6 +232,16 @@ def admin_required(f):
|
||||
return wrapped
|
||||
|
||||
|
||||
def user_login_required(f):
|
||||
from functools import wraps
|
||||
@wraps(f)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not _get_current_user():
|
||||
return redirect(url_for("user_login", next=request.path))
|
||||
return f(*args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
plans = VPSPlan.query.order_by(VPSPlan.provider, VPSPlan.price_cny).all()
|
||||
@@ -94,6 +259,132 @@ def api_plans():
|
||||
return jsonify([p.to_dict() for p in plans])
|
||||
|
||||
|
||||
# ---------- 前台用户与论坛 ----------
|
||||
@app.route("/register", methods=["GET", "POST"])
|
||||
def user_register():
|
||||
if _get_current_user():
|
||||
return redirect(url_for("forum_index"))
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
username = (request.form.get("username") or "").strip()
|
||||
password = request.form.get("password") or ""
|
||||
confirm_password = request.form.get("confirm_password") or ""
|
||||
|
||||
if not _is_valid_username(username):
|
||||
error = "用户名需为 3-20 位,仅支持字母、数字、下划线"
|
||||
elif len(password) < 6:
|
||||
error = "密码至少 6 位"
|
||||
elif password != confirm_password:
|
||||
error = "两次输入的密码不一致"
|
||||
elif User.query.filter(func.lower(User.username) == username.lower()).first():
|
||||
error = "用户名已存在"
|
||||
else:
|
||||
user = User(username=username)
|
||||
user.set_password(password)
|
||||
user.last_login_at = datetime.utcnow()
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
session["user_id"] = user.id
|
||||
return redirect(_safe_next_url("forum_index"))
|
||||
|
||||
return render_template("auth/register.html", error=error)
|
||||
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def user_login():
|
||||
if _get_current_user():
|
||||
return redirect(url_for("forum_index"))
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
username = (request.form.get("username") or "").strip()
|
||||
password = request.form.get("password") or ""
|
||||
user = User.query.filter(func.lower(User.username) == username.lower()).first()
|
||||
if not user or not user.check_password(password):
|
||||
error = "用户名或密码错误"
|
||||
else:
|
||||
user.last_login_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
session["user_id"] = user.id
|
||||
return redirect(_safe_next_url("forum_index"))
|
||||
return render_template("auth/login.html", error=error)
|
||||
|
||||
|
||||
@app.route("/logout")
|
||||
def user_logout():
|
||||
session.pop("user_id", None)
|
||||
return redirect(url_for("forum_index"))
|
||||
|
||||
|
||||
@app.route("/forum")
|
||||
def forum_index():
|
||||
posts = ForumPost.query.order_by(ForumPost.created_at.desc(), ForumPost.id.desc()).all()
|
||||
return render_template("forum/index.html", posts=posts)
|
||||
|
||||
|
||||
@app.route("/forum/post/new", methods=["GET", "POST"])
|
||||
@user_login_required
|
||||
def forum_post_new():
|
||||
user = _get_current_user()
|
||||
error = None
|
||||
title = ""
|
||||
content = ""
|
||||
if request.method == "POST":
|
||||
title = (request.form.get("title") or "").strip()
|
||||
content = (request.form.get("content") or "").strip()
|
||||
if len(title) < 5:
|
||||
error = "标题至少 5 个字符"
|
||||
elif len(title) > 160:
|
||||
error = "标题不能超过 160 个字符"
|
||||
elif len(content) < 10:
|
||||
error = "内容至少 10 个字符"
|
||||
else:
|
||||
post = ForumPost(
|
||||
user_id=user.id,
|
||||
title=title,
|
||||
content=content,
|
||||
)
|
||||
db.session.add(post)
|
||||
db.session.commit()
|
||||
return redirect(url_for("forum_post_detail", post_id=post.id))
|
||||
return render_template("forum/post_form.html", error=error, title_val=title, content_val=content)
|
||||
|
||||
|
||||
@app.route("/forum/post/<int:post_id>")
|
||||
def forum_post_detail(post_id):
|
||||
post = ForumPost.query.get_or_404(post_id)
|
||||
comments = (
|
||||
ForumComment.query
|
||||
.filter_by(post_id=post.id)
|
||||
.order_by(ForumComment.created_at.asc(), ForumComment.id.asc())
|
||||
.all()
|
||||
)
|
||||
return render_template(
|
||||
"forum/post_detail.html",
|
||||
post=post,
|
||||
comments=comments,
|
||||
message=request.args.get("msg") or "",
|
||||
error=request.args.get("error") or "",
|
||||
)
|
||||
|
||||
|
||||
@app.route("/forum/post/<int:post_id>/comment", methods=["POST"])
|
||||
@user_login_required
|
||||
def forum_post_comment(post_id):
|
||||
post = ForumPost.query.get_or_404(post_id)
|
||||
user = _get_current_user()
|
||||
content = (request.form.get("content") or "").strip()
|
||||
if len(content) < 2:
|
||||
return redirect(url_for("forum_post_detail", post_id=post.id, error="评论至少 2 个字符"))
|
||||
comment = ForumComment(
|
||||
post_id=post.id,
|
||||
user_id=user.id,
|
||||
content=content,
|
||||
)
|
||||
db.session.add(comment)
|
||||
db.session.commit()
|
||||
return redirect(url_for("forum_post_detail", post_id=post.id, msg="评论发布成功"))
|
||||
|
||||
|
||||
# ---------- SEO ----------
|
||||
@app.route("/sitemap.xml")
|
||||
def sitemap():
|
||||
@@ -106,6 +397,11 @@ def sitemap():
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>{url}/forum</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
</urlset>'''
|
||||
resp = make_response(xml)
|
||||
resp.mimetype = "application/xml"
|
||||
@@ -164,15 +460,41 @@ def admin_api_plan(plan_id):
|
||||
})
|
||||
|
||||
|
||||
@app.route("/admin/api/plan/<int:plan_id>/price-history")
|
||||
@admin_required
|
||||
def admin_api_plan_price_history(plan_id):
|
||||
plan = VPSPlan.query.get_or_404(plan_id)
|
||||
rows = (
|
||||
PriceHistory.query
|
||||
.filter_by(plan_id=plan.id)
|
||||
.order_by(PriceHistory.captured_at.desc(), PriceHistory.id.desc())
|
||||
.limit(30)
|
||||
.all()
|
||||
)
|
||||
return jsonify([
|
||||
{
|
||||
"id": r.id,
|
||||
"price_cny": float(r.price_cny) if r.price_cny is not None else None,
|
||||
"price_usd": float(r.price_usd) if r.price_usd is not None else None,
|
||||
"currency": r.currency or "CNY",
|
||||
"source": r.source or "",
|
||||
"captured_at": r.captured_at.isoformat() if r.captured_at else "",
|
||||
}
|
||||
for r in rows
|
||||
])
|
||||
|
||||
|
||||
@app.route("/admin")
|
||||
@admin_required
|
||||
def admin_dashboard():
|
||||
providers = Provider.query.order_by(Provider.name).all()
|
||||
plans = VPSPlan.query.order_by(VPSPlan.provider, VPSPlan.price_cny).all()
|
||||
plan_trends = _build_plan_trend_map(plans)
|
||||
return render_template(
|
||||
"admin/dashboard.html",
|
||||
providers=providers,
|
||||
plans=plans,
|
||||
plan_trends=plan_trends,
|
||||
country_tags=COUNTRY_TAGS,
|
||||
)
|
||||
|
||||
@@ -210,10 +532,12 @@ def admin_provider_detail(provider_id):
|
||||
(VPSPlan.provider_id == provider_id) | (VPSPlan.provider == provider.name)
|
||||
).order_by(VPSPlan.price_cny.asc(), VPSPlan.name).all()
|
||||
providers = Provider.query.order_by(Provider.name).all()
|
||||
plan_trends = _build_plan_trend_map(plans)
|
||||
return render_template(
|
||||
"admin/provider_detail.html",
|
||||
provider=provider,
|
||||
plans=plans,
|
||||
plan_trends=plan_trends,
|
||||
providers=providers,
|
||||
country_tags=COUNTRY_TAGS,
|
||||
)
|
||||
@@ -350,6 +674,8 @@ def _save_plan(plan):
|
||||
plan.official_url = official_url
|
||||
plan.countries = countries
|
||||
|
||||
db.session.flush()
|
||||
_record_price_history(plan, source="manual")
|
||||
db.session.commit()
|
||||
# 若从厂商详情页进入添加,保存后返回该厂商详情
|
||||
from_provider_id = request.form.get("from_provider_id", type=int)
|
||||
@@ -362,6 +688,7 @@ def _save_plan(plan):
|
||||
@admin_required
|
||||
def admin_plan_delete(plan_id):
|
||||
plan = VPSPlan.query.get_or_404(plan_id)
|
||||
PriceHistory.query.filter_by(plan_id=plan_id).delete()
|
||||
db.session.delete(plan)
|
||||
db.session.commit()
|
||||
return redirect(url_for("admin_dashboard"))
|
||||
@@ -409,27 +736,6 @@ def admin_export_excel():
|
||||
)
|
||||
|
||||
|
||||
def _plan_match(plan, row):
|
||||
"""判断 DB 中的 plan 与导入行是否同一配置(厂商+配置+价格)。"""
|
||||
def eq(a, b):
|
||||
if a is None and b in (None, ""):
|
||||
return True
|
||||
if a is None or b in (None, ""):
|
||||
return a == b
|
||||
return a == b
|
||||
|
||||
return (
|
||||
plan.provider_name == (row.get("厂商") or "").strip()
|
||||
and eq(plan.vcpu, _num(row.get("vCPU")))
|
||||
and eq(plan.memory_gb, _num(row.get("内存GB")))
|
||||
and eq(plan.storage_gb, _num(row.get("存储GB")))
|
||||
and eq(plan.bandwidth_mbps, _num(row.get("带宽Mbps")))
|
||||
and (plan.countries or "").strip() == (row.get("国家") or "").strip()
|
||||
and eq(plan.price_cny, _float(row.get("月付人民币")))
|
||||
and eq(plan.price_usd, _float(row.get("月付美元")))
|
||||
)
|
||||
|
||||
|
||||
def _num(v):
|
||||
if v is None or v == "":
|
||||
return None
|
||||
@@ -448,6 +754,149 @@ def _float(v):
|
||||
return None
|
||||
|
||||
|
||||
def _opt_text(v):
|
||||
if v is None:
|
||||
return None
|
||||
s = str(v).strip()
|
||||
return s or None
|
||||
|
||||
|
||||
def _safe_str(v):
|
||||
if v is None:
|
||||
return ""
|
||||
return str(v).strip()
|
||||
|
||||
|
||||
def _eq_optional(a, b):
|
||||
if a is None and b is None:
|
||||
return True
|
||||
if a is None or b is None:
|
||||
return False
|
||||
if isinstance(a, float) or isinstance(b, float):
|
||||
return abs(float(a) - float(b)) < 1e-9
|
||||
return a == b
|
||||
|
||||
|
||||
def _record_price_history(plan, source):
|
||||
if plan is None:
|
||||
return
|
||||
if plan.price_cny is None and plan.price_usd is None:
|
||||
return
|
||||
if plan.id is None:
|
||||
db.session.flush()
|
||||
latest = (
|
||||
PriceHistory.query
|
||||
.filter_by(plan_id=plan.id)
|
||||
.order_by(PriceHistory.captured_at.desc(), PriceHistory.id.desc())
|
||||
.first()
|
||||
)
|
||||
currency = _opt_text(plan.currency) or "CNY"
|
||||
if latest:
|
||||
same_currency = _safe_str(latest.currency).upper() == _safe_str(currency).upper()
|
||||
if same_currency and _eq_optional(latest.price_cny, plan.price_cny) and _eq_optional(latest.price_usd, plan.price_usd):
|
||||
return
|
||||
db.session.add(PriceHistory(
|
||||
plan_id=plan.id,
|
||||
price_cny=plan.price_cny,
|
||||
price_usd=plan.price_usd,
|
||||
currency=currency,
|
||||
source=source,
|
||||
))
|
||||
|
||||
|
||||
def _display_val(v):
|
||||
if v is None or v == "":
|
||||
return "—"
|
||||
if isinstance(v, float):
|
||||
s = "{:.2f}".format(v).rstrip("0").rstrip(".")
|
||||
return s if s else "0"
|
||||
return str(v)
|
||||
|
||||
|
||||
def _row_identity_key(row):
|
||||
return (
|
||||
_safe_str(row.get("厂商")),
|
||||
_num(row.get("vCPU")),
|
||||
_num(row.get("内存GB")),
|
||||
_num(row.get("存储GB")),
|
||||
_num(row.get("带宽Mbps")),
|
||||
_safe_str(row.get("国家")),
|
||||
_safe_str(row.get("流量")),
|
||||
)
|
||||
|
||||
|
||||
def _plan_identity_key(plan):
|
||||
return (
|
||||
_safe_str(plan.provider_name),
|
||||
plan.vcpu,
|
||||
plan.memory_gb,
|
||||
plan.storage_gb,
|
||||
plan.bandwidth_mbps,
|
||||
_safe_str(plan.countries),
|
||||
_safe_str(plan.traffic),
|
||||
)
|
||||
|
||||
|
||||
def _plan_diff(plan, row):
|
||||
"""返回导入行相对于现有 plan 的差异列表。"""
|
||||
fields = [
|
||||
("国家", "countries", _opt_text(row.get("国家"))),
|
||||
("vCPU", "vcpu", _num(row.get("vCPU"))),
|
||||
("内存GB", "memory_gb", _num(row.get("内存GB"))),
|
||||
("存储GB", "storage_gb", _num(row.get("存储GB"))),
|
||||
("带宽Mbps", "bandwidth_mbps", _num(row.get("带宽Mbps"))),
|
||||
("流量", "traffic", _opt_text(row.get("流量"))),
|
||||
("月付人民币", "price_cny", _float(row.get("月付人民币"))),
|
||||
("月付美元", "price_usd", _float(row.get("月付美元"))),
|
||||
("货币", "currency", _opt_text(row.get("货币")) or "CNY"),
|
||||
("配置官网", "official_url", _opt_text(row.get("配置官网"))),
|
||||
]
|
||||
diffs = []
|
||||
for label, attr, new_value in fields:
|
||||
old_value = getattr(plan, attr)
|
||||
if not _eq_optional(old_value, new_value):
|
||||
diffs.append({
|
||||
"label": label,
|
||||
"old": old_value,
|
||||
"new": new_value,
|
||||
"old_display": _display_val(old_value),
|
||||
"new_display": _display_val(new_value),
|
||||
})
|
||||
return diffs
|
||||
|
||||
|
||||
def _upsert_provider_from_row(row):
|
||||
provider_name = _safe_str(row.get("厂商"))
|
||||
if not provider_name:
|
||||
return None
|
||||
imported_provider_url = _opt_text(row.get("厂商官网"))
|
||||
provider = Provider.query.filter_by(name=provider_name).first()
|
||||
if not provider:
|
||||
provider = Provider(name=provider_name, official_url=imported_provider_url)
|
||||
db.session.add(provider)
|
||||
db.session.flush()
|
||||
elif imported_provider_url and provider.official_url != imported_provider_url:
|
||||
provider.official_url = imported_provider_url
|
||||
return provider
|
||||
|
||||
|
||||
def _fill_plan_from_row(plan, row, provider):
|
||||
plan.provider_id = provider.id
|
||||
plan.provider = provider.name
|
||||
plan.region = None
|
||||
plan.name = None
|
||||
plan.vcpu = _num(row.get("vCPU"))
|
||||
plan.memory_gb = _num(row.get("内存GB"))
|
||||
plan.storage_gb = _num(row.get("存储GB"))
|
||||
plan.bandwidth_mbps = _num(row.get("带宽Mbps"))
|
||||
plan.traffic = _opt_text(row.get("流量"))
|
||||
plan.price_cny = _float(row.get("月付人民币"))
|
||||
plan.price_usd = _float(row.get("月付美元"))
|
||||
plan.currency = _opt_text(row.get("货币")) or "CNY"
|
||||
plan.official_url = _opt_text(row.get("配置官网"))
|
||||
plan.countries = _opt_text(row.get("国家"))
|
||||
|
||||
|
||||
@app.route("/admin/import", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def admin_import():
|
||||
@@ -482,66 +931,101 @@ def admin_import():
|
||||
if not parsed:
|
||||
return render_template("admin/import.html", error="文件中没有有效数据行")
|
||||
plans = VPSPlan.query.all()
|
||||
to_add = []
|
||||
plan_index = {}
|
||||
for p in plans:
|
||||
key = _plan_identity_key(p)
|
||||
if key not in plan_index:
|
||||
plan_index[key] = p
|
||||
seen_row_keys = set()
|
||||
preview_items = []
|
||||
for row in parsed:
|
||||
provider_name = (row.get("厂商") or "").strip()
|
||||
key = _row_identity_key(row)
|
||||
provider_name = key[0]
|
||||
if not provider_name:
|
||||
continue
|
||||
found = False
|
||||
for p in plans:
|
||||
if _plan_match(p, row):
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
to_add.append(row)
|
||||
session["import_preview"] = to_add
|
||||
if key in seen_row_keys:
|
||||
continue
|
||||
seen_row_keys.add(key)
|
||||
matched = plan_index.get(key)
|
||||
if not matched:
|
||||
preview_items.append({
|
||||
"action": "add",
|
||||
"row": row,
|
||||
"changes": [],
|
||||
"provider_url_changed": False,
|
||||
})
|
||||
continue
|
||||
changes = _plan_diff(matched, row)
|
||||
imported_provider_url = _opt_text(row.get("厂商官网"))
|
||||
old_provider_url = _opt_text(matched.provider_rel.official_url if matched.provider_rel else None)
|
||||
provider_url_changed = bool(imported_provider_url and imported_provider_url != old_provider_url)
|
||||
if changes or provider_url_changed:
|
||||
preview_items.append({
|
||||
"action": "update",
|
||||
"plan_id": matched.id,
|
||||
"row": row,
|
||||
"changes": changes,
|
||||
"provider_url_changed": provider_url_changed,
|
||||
"provider_url_old": old_provider_url,
|
||||
"provider_url_new": imported_provider_url,
|
||||
})
|
||||
session["import_preview"] = preview_items
|
||||
return redirect(url_for("admin_import_preview"))
|
||||
|
||||
|
||||
@app.route("/admin/import/preview", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def admin_import_preview():
|
||||
to_add = session.get("import_preview") or []
|
||||
preview_items = session.get("import_preview") or []
|
||||
add_count = sum(1 for x in preview_items if x.get("action") == "add")
|
||||
update_count = sum(1 for x in preview_items if x.get("action") == "update")
|
||||
if request.method == "GET":
|
||||
return render_template("admin/import_preview.html", rows=list(enumerate(to_add)))
|
||||
return render_template(
|
||||
"admin/import_preview.html",
|
||||
rows=list(enumerate(preview_items)),
|
||||
add_count=add_count,
|
||||
update_count=update_count,
|
||||
)
|
||||
selected = request.form.getlist("row_index")
|
||||
if not selected:
|
||||
to_add = session.get("import_preview") or []
|
||||
return render_template("admin/import_preview.html", rows=list(enumerate(to_add)), error="请至少勾选一行")
|
||||
to_add = session.get("import_preview") or []
|
||||
indices = set(int(x) for x in selected if x.isdigit())
|
||||
for i in indices:
|
||||
if i < 0 or i >= len(to_add):
|
||||
continue
|
||||
row = to_add[i]
|
||||
provider_name = (row.get("厂商") or "").strip()
|
||||
if not provider_name:
|
||||
continue
|
||||
provider = Provider.query.filter_by(name=provider_name).first()
|
||||
if not provider:
|
||||
provider = Provider(name=provider_name, official_url=(row.get("厂商官网") or "").strip() or None)
|
||||
db.session.add(provider)
|
||||
db.session.flush()
|
||||
plan = VPSPlan(
|
||||
provider_id=provider.id,
|
||||
provider=provider.name,
|
||||
region=None,
|
||||
name=None,
|
||||
vcpu=_num(row.get("vCPU")),
|
||||
memory_gb=_num(row.get("内存GB")),
|
||||
storage_gb=_num(row.get("存储GB")),
|
||||
bandwidth_mbps=_num(row.get("带宽Mbps")),
|
||||
traffic=(row.get("流量") or "").strip() or None,
|
||||
price_cny=_float(row.get("月付人民币")),
|
||||
price_usd=_float(row.get("月付美元")),
|
||||
currency=(row.get("货币") or "CNY").strip() or "CNY",
|
||||
official_url=(row.get("配置官网") or "").strip() or None,
|
||||
countries=(row.get("国家") or "").strip() or None,
|
||||
return render_template(
|
||||
"admin/import_preview.html",
|
||||
rows=list(enumerate(preview_items)),
|
||||
add_count=add_count,
|
||||
update_count=update_count,
|
||||
error="请至少勾选一行",
|
||||
)
|
||||
db.session.add(plan)
|
||||
indices = sorted(set(int(x) for x in selected if x.isdigit()))
|
||||
add_applied = 0
|
||||
update_applied = 0
|
||||
for i in indices:
|
||||
if i < 0 or i >= len(preview_items):
|
||||
continue
|
||||
item = preview_items[i]
|
||||
row = item.get("row") or {}
|
||||
provider = _upsert_provider_from_row(row)
|
||||
if not provider:
|
||||
continue
|
||||
action = item.get("action")
|
||||
if action == "update":
|
||||
plan = VPSPlan.query.get(item.get("plan_id"))
|
||||
if not plan:
|
||||
plan = VPSPlan()
|
||||
db.session.add(plan)
|
||||
add_applied += 1
|
||||
else:
|
||||
update_applied += 1
|
||||
_fill_plan_from_row(plan, row, provider)
|
||||
else:
|
||||
plan = VPSPlan()
|
||||
_fill_plan_from_row(plan, row, provider)
|
||||
db.session.add(plan)
|
||||
add_applied += 1
|
||||
_record_price_history(plan, source="import")
|
||||
db.session.commit()
|
||||
session.pop("import_preview", None)
|
||||
return redirect(url_for("admin_dashboard") + "?" + urlencode({"msg": "已导入 {} 条配置".format(len(indices))}))
|
||||
msg = "已新增 {} 条,更新 {} 条配置".format(add_applied, update_applied)
|
||||
return redirect(url_for("admin_dashboard") + "?" + urlencode({"msg": msg}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
15
init_db.py
15
init_db.py
@@ -7,7 +7,7 @@ import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from app import app, db
|
||||
from models import VPSPlan
|
||||
from models import VPSPlan, PriceHistory
|
||||
|
||||
# 默认官网链接(可按方案自定义)
|
||||
DEFAULT_URLS = {
|
||||
@@ -54,6 +54,19 @@ def main():
|
||||
)
|
||||
db.session.add(plan)
|
||||
db.session.commit()
|
||||
for plan in VPSPlan.query.all():
|
||||
if plan.price_cny is None and plan.price_usd is None:
|
||||
continue
|
||||
if PriceHistory.query.filter_by(plan_id=plan.id).first():
|
||||
continue
|
||||
db.session.add(PriceHistory(
|
||||
plan_id=plan.id,
|
||||
price_cny=plan.price_cny,
|
||||
price_usd=plan.price_usd,
|
||||
currency=plan.currency or "CNY",
|
||||
source="bootstrap",
|
||||
))
|
||||
db.session.commit()
|
||||
print("已导入 %d 条初始方案。" % len(INITIAL_PLANS))
|
||||
else:
|
||||
print("数据库中已有数据,跳过导入。若要重新初始化请删除 vps_price.db 后重试。")
|
||||
|
||||
88
models.py
88
models.py
@@ -1,5 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""数据库模型"""
|
||||
from datetime import datetime
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from extensions import db
|
||||
|
||||
|
||||
@@ -14,6 +16,40 @@ class Provider(db.Model):
|
||||
plans = db.relationship("VPSPlan", backref="provider_rel", lazy="dynamic", foreign_keys="VPSPlan.provider_id")
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
"""前台用户"""
|
||||
__tablename__ = "users"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(32), nullable=False, unique=True, index=True)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, index=True)
|
||||
last_login_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
posts = db.relationship(
|
||||
"ForumPost",
|
||||
backref="author_rel",
|
||||
lazy="dynamic",
|
||||
cascade="all, delete-orphan",
|
||||
foreign_keys="ForumPost.user_id",
|
||||
)
|
||||
comments = db.relationship(
|
||||
"ForumComment",
|
||||
backref="author_rel",
|
||||
lazy="dynamic",
|
||||
cascade="all, delete-orphan",
|
||||
foreign_keys="ForumComment.user_id",
|
||||
)
|
||||
|
||||
def set_password(self, raw_password):
|
||||
self.password_hash = generate_password_hash(raw_password)
|
||||
|
||||
def check_password(self, raw_password):
|
||||
if not self.password_hash:
|
||||
return False
|
||||
return check_password_hash(self.password_hash, raw_password)
|
||||
|
||||
|
||||
class VPSPlan(db.Model):
|
||||
"""云服务器配置/方案(属于某厂商)"""
|
||||
__tablename__ = "vps_plans"
|
||||
@@ -33,6 +69,13 @@ class VPSPlan(db.Model):
|
||||
currency = db.Column(db.String(8), default="CNY")
|
||||
official_url = db.Column(db.String(512), nullable=True) # 该配置详情页,可覆盖厂商默认
|
||||
countries = db.Column(db.String(255), nullable=True, index=True)
|
||||
price_histories = db.relationship(
|
||||
"PriceHistory",
|
||||
backref="plan_rel",
|
||||
lazy="dynamic",
|
||||
cascade="all, delete-orphan",
|
||||
foreign_keys="PriceHistory.plan_id",
|
||||
)
|
||||
|
||||
@property
|
||||
def provider_name(self):
|
||||
@@ -73,3 +116,48 @@ class VPSPlan(db.Model):
|
||||
"official_url": self.official_url or (self.provider_rel.official_url if self.provider_rel else "") or "",
|
||||
"countries": self.countries or "",
|
||||
}
|
||||
|
||||
|
||||
class PriceHistory(db.Model):
|
||||
"""配置价格历史快照"""
|
||||
__tablename__ = "price_histories"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
plan_id = db.Column(db.Integer, db.ForeignKey("vps_plans.id"), nullable=False, index=True)
|
||||
price_cny = db.Column(db.Float, nullable=True)
|
||||
price_usd = db.Column(db.Float, nullable=True)
|
||||
currency = db.Column(db.String(8), nullable=False, default="CNY")
|
||||
source = db.Column(db.String(32), nullable=True) # manual / import / bootstrap
|
||||
captured_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, index=True)
|
||||
|
||||
|
||||
class ForumPost(db.Model):
|
||||
"""论坛帖子"""
|
||||
__tablename__ = "forum_posts"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
|
||||
title = db.Column(db.String(160), nullable=False, index=True)
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, index=True)
|
||||
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
comments = db.relationship(
|
||||
"ForumComment",
|
||||
backref="post_rel",
|
||||
lazy="dynamic",
|
||||
cascade="all, delete-orphan",
|
||||
foreign_keys="ForumComment.post_id",
|
||||
)
|
||||
|
||||
|
||||
class ForumComment(db.Model):
|
||||
"""论坛评论"""
|
||||
__tablename__ = "forum_comments"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
post_id = db.Column(db.Integer, db.ForeignKey("forum_posts.id"), nullable=False, index=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, index=True)
|
||||
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
@@ -204,6 +204,40 @@
|
||||
color: var(--accent-dim);
|
||||
}
|
||||
|
||||
.price-trend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.price-trend .trend-delta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.price-trend .trend-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.price-trend.trend-up .trend-delta {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.price-trend.trend-down .trend-delta {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.price-trend.trend-flat .trend-delta {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.price-trend.trend-new .trend-delta {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.admin-table .btn-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
237
static/css/forum.css
Normal file
237
static/css/forum.css
Normal file
@@ -0,0 +1,237 @@
|
||||
.forum-topbar {
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.forum-topbar-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0.85rem 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.forum-brand {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.forum-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.forum-nav a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.forum-nav a.active {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.forum-nav .forum-user {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.forum-main {
|
||||
width: 100%;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.forum-section {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.forum-section h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.8rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.forum-section h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.8rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.forum-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.post-list,
|
||||
.comment-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.post-item,
|
||||
.comment-item {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 0.9rem;
|
||||
margin-bottom: 0.7rem;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.post-title {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.post-title:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.post-meta,
|
||||
.comment-meta {
|
||||
margin-top: 0.45rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.post-content,
|
||||
.comment-content {
|
||||
white-space: pre-wrap;
|
||||
margin-top: 0.8rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.forum-btn {
|
||||
display: inline-block;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.forum-btn:hover {
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.forum-btn-ghost {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.forum-btn-ghost:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
max-width: 420px;
|
||||
margin: 1.8rem auto;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.1rem 1.2rem;
|
||||
}
|
||||
|
||||
.auth-card h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.auth-form .form-group,
|
||||
.post-form .form-group,
|
||||
.comment-form .form-group {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.auth-form label,
|
||||
.post-form label,
|
||||
.comment-form label {
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.auth-form input,
|
||||
.post-form input,
|
||||
.post-form textarea,
|
||||
.comment-form textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 0.65rem;
|
||||
font-size: 0.94rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.auth-form input:focus,
|
||||
.post-form input:focus,
|
||||
.post-form textarea:focus,
|
||||
.comment-form textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.auth-form button,
|
||||
.post-form button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: var(--red);
|
||||
font-size: 0.88rem;
|
||||
margin: 0 0 0.8rem 0;
|
||||
}
|
||||
|
||||
.form-success {
|
||||
color: var(--green);
|
||||
font-size: 0.88rem;
|
||||
margin: 0 0 0.8rem 0;
|
||||
}
|
||||
|
||||
.forum-empty {
|
||||
color: var(--text-muted);
|
||||
margin: 0.6rem 0;
|
||||
}
|
||||
|
||||
.auth-switch {
|
||||
margin-top: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.auth-switch a {
|
||||
color: var(--accent);
|
||||
}
|
||||
@@ -31,6 +31,35 @@
|
||||
USD: 0.14
|
||||
};
|
||||
|
||||
function toNumber(value) {
|
||||
if (value == null || value === '') return null;
|
||||
var n = Number(value);
|
||||
return isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
function getPriceValue(plan, currency) {
|
||||
var cny = toNumber(plan.price_cny);
|
||||
var usd = toNumber(plan.price_usd);
|
||||
|
||||
if (currency === 'USD') {
|
||||
if (usd != null) return { value: usd, symbol: '$' };
|
||||
if (cny != null) return { value: cny * exchangeRates.USD, symbol: '$' };
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cny != null) return { value: cny, symbol: '¥' };
|
||||
if (usd != null) return { value: usd / exchangeRates.USD, symbol: '¥' };
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCnyPrice(plan) {
|
||||
var cny = toNumber(plan.price_cny);
|
||||
if (cny != null) return cny;
|
||||
var usd = toNumber(plan.price_usd);
|
||||
if (usd != null) return usd / exchangeRates.USD;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
function init() {
|
||||
fetchData();
|
||||
@@ -213,7 +242,9 @@
|
||||
var range = filters.price.split('-');
|
||||
var min = parseFloat(range[0]);
|
||||
var max = parseFloat(range[1]);
|
||||
if (plan.price_cny < min || plan.price_cny > max) return false;
|
||||
var cnyPrice = getCnyPrice(plan);
|
||||
if (cnyPrice == null) return false;
|
||||
if (cnyPrice < min || cnyPrice > max) return false;
|
||||
}
|
||||
|
||||
// 搜索筛选
|
||||
@@ -230,8 +261,17 @@
|
||||
if (!currentSort.column) return plans;
|
||||
|
||||
return plans.slice().sort(function(a, b) {
|
||||
var aVal = a[currentSort.column];
|
||||
var bVal = b[currentSort.column];
|
||||
var aVal;
|
||||
var bVal;
|
||||
if (currentSort.column === 'price') {
|
||||
var aPrice = getPriceValue(a, filters.currency);
|
||||
var bPrice = getPriceValue(b, filters.currency);
|
||||
aVal = aPrice ? aPrice.value : Number.POSITIVE_INFINITY;
|
||||
bVal = bPrice ? bPrice.value : Number.POSITIVE_INFINITY;
|
||||
} else {
|
||||
aVal = a[currentSort.column];
|
||||
bVal = b[currentSort.column];
|
||||
}
|
||||
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
return currentSort.direction === 'asc' ? aVal - bVal : bVal - aVal;
|
||||
@@ -250,9 +290,8 @@
|
||||
|
||||
function createTableRow(plan) {
|
||||
var tr = document.createElement('tr');
|
||||
|
||||
var price = convertPrice(plan.price_cny, filters.currency);
|
||||
var priceSymbol = filters.currency === 'CNY' ? '¥' : '$';
|
||||
var currentPrice = getPriceValue(plan, filters.currency);
|
||||
var displayPrice = currentPrice ? currentPrice.symbol + currentPrice.value.toFixed(2) : '—';
|
||||
|
||||
tr.innerHTML =
|
||||
'<td>' + escapeHtml(plan.provider) + '</td>' +
|
||||
@@ -263,7 +302,7 @@
|
||||
'<td>' + plan.storage_gb + ' GB</td>' +
|
||||
'<td>' + (plan.bandwidth_mbps ? plan.bandwidth_mbps + ' Mbps' : '-') + '</td>' +
|
||||
'<td>' + plan.traffic + '</td>' +
|
||||
'<td class="col-price">' + priceSymbol + price + '</td>' +
|
||||
'<td class="col-price">' + displayPrice + '</td>' +
|
||||
'<td class="col-link">' +
|
||||
'<a href="' + escapeHtml(plan.official_url) + '" target="_blank" rel="noopener" class="btn-link">访问</a>' +
|
||||
'</td>';
|
||||
@@ -271,12 +310,6 @@
|
||||
return tr;
|
||||
}
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
function convertPrice(priceCNY, currency) {
|
||||
var converted = priceCNY * exchangeRates[currency];
|
||||
return converted.toFixed(2);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
<th>内存</th>
|
||||
<th>流量</th>
|
||||
<th>月付</th>
|
||||
<th>涨跌趋势</th>
|
||||
<th>官网链接</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
@@ -71,6 +72,17 @@
|
||||
<td>{{ plan.memory_gb ~ ' GB' if plan.memory_gb is not none else '—' }}</td>
|
||||
<td>{{ plan.traffic or '—' }}</td>
|
||||
<td>{% if plan.price_cny is not none %}¥{{ plan.price_cny }}{% elif plan.price_usd is not none %}${{ plan.price_usd }}{% else %}—{% endif %}</td>
|
||||
<td>
|
||||
{% set trend = plan_trends.get(plan.id) %}
|
||||
{% if trend %}
|
||||
<div class="price-trend trend-{{ trend.direction }}">
|
||||
<span class="trend-delta">{{ trend.delta_text }}</span>
|
||||
<span class="trend-meta">{{ trend.meta_text }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% if plan.official_url %}<a href="{{ plan.official_url }}" target="_blank" rel="noopener">链接</a>{% else %}—{% endif %}</td>
|
||||
<td>
|
||||
<button type="button" class="btn-inline btn-edit" data-plan-id="{{ plan.id }}">编辑</button>
|
||||
@@ -81,7 +93,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9">暂无配置。</td></tr>
|
||||
<tr><td colspan="10">暂无配置。</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</nav>
|
||||
</header>
|
||||
<main class="admin-main">
|
||||
<p class="hint">上传与导出格式一致的 .xlsx,系统会与现有数据比对,<strong>仅将「厂商+配置+价格」在库中不存在的行</strong>列为待确认;确认后才会写入数据库并展示。</p>
|
||||
<p class="hint">上传与导出格式一致的 .xlsx,系统会与现有数据比对,自动识别<strong>新增项</strong>和<strong>可更新项</strong>;在预览页勾选确认后再写入数据库。</p>
|
||||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
@@ -28,7 +28,7 @@
|
||||
<input type="file" id="file" name="file" accept=".xlsx" required>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit">上传并预览待新增</button>
|
||||
<button type="submit">上传并预览待处理项</button>
|
||||
<a href="{{ url_for('admin_dashboard') }}" class="btn-cancel">取消</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -17,18 +17,19 @@
|
||||
</nav>
|
||||
</header>
|
||||
<main class="admin-main">
|
||||
<p class="hint">以下为与现有数据不重复的配置,勾选需要写入的项后点击「确认导入」即可正式入库并展示。</p>
|
||||
<p class="hint">本次解析结果:新增 {{ add_count or 0 }} 条,可更新 {{ update_count or 0 }} 条。勾选后点击「确认导入」执行写入。</p>
|
||||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
{% if not rows %}
|
||||
<p>没有待新增数据(可能全部已存在)。<a href="{{ url_for('admin_import') }}">重新上传</a></p>
|
||||
<p>没有待处理数据(可能全部已存在且无变化)。<a href="{{ url_for('admin_import') }}">重新上传</a></p>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('admin_import_preview') }}">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="check-all" checked></th>
|
||||
<th>动作</th>
|
||||
<th>厂商</th>
|
||||
<th>国家</th>
|
||||
<th>vCPU</th>
|
||||
@@ -37,13 +38,16 @@
|
||||
<th>流量</th>
|
||||
<th>月付¥</th>
|
||||
<th>月付$</th>
|
||||
<th>变更</th>
|
||||
<th>配置官网</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for idx, row in rows %}
|
||||
{% for idx, item in rows %}
|
||||
{% set row = item.get('row', {}) %}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="row_index" value="{{ idx }}" checked></td>
|
||||
<td>{% if item.get('action') == 'update' %}更新 #{{ item.get('plan_id') }}{% else %}新增{% endif %}</td>
|
||||
<td>{{ row.get('厂商') or '—' }}</td>
|
||||
<td>{{ row.get('国家') or '—' }}</td>
|
||||
<td>{{ row.get('vCPU') or '—' }}</td>
|
||||
@@ -52,6 +56,18 @@
|
||||
<td>{{ row.get('流量') or '—' }}</td>
|
||||
<td>{{ row.get('月付人民币') or '—' }}</td>
|
||||
<td>{{ row.get('月付美元') or '—' }}</td>
|
||||
<td>
|
||||
{% if item.get('action') == 'add' %}
|
||||
新配置
|
||||
{% else %}
|
||||
{% for c in item.get('changes', []) %}
|
||||
<div>{{ c.get('label') }}:{{ c.get('old_display') }} → {{ c.get('new_display') }}</div>
|
||||
{% endfor %}
|
||||
{% if item.get('provider_url_changed') %}
|
||||
<div>厂商官网:{{ item.get('provider_url_old') or '—' }} → {{ item.get('provider_url_new') or '—' }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ (row.get('配置官网') or '')[:30] }}{% if (row.get('配置官网') or '')|length > 30 %}…{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<th>内存</th>
|
||||
<th>流量</th>
|
||||
<th>月付</th>
|
||||
<th>涨跌趋势</th>
|
||||
<th>官网链接</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
@@ -50,6 +51,17 @@
|
||||
<td>{{ plan.memory_gb ~ ' GB' if plan.memory_gb is not none else '—' }}</td>
|
||||
<td>{{ plan.traffic or '—' }}</td>
|
||||
<td>{% if plan.price_cny is not none %}¥{{ plan.price_cny }}{% elif plan.price_usd is not none %}${{ plan.price_usd }}{% else %}—{% endif %}</td>
|
||||
<td>
|
||||
{% set trend = plan_trends.get(plan.id) %}
|
||||
{% if trend %}
|
||||
<div class="price-trend trend-{{ trend.direction }}">
|
||||
<span class="trend-delta">{{ trend.delta_text }}</span>
|
||||
<span class="trend-meta">{{ trend.meta_text }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% if plan.official_url %}<a href="{{ plan.official_url }}" target="_blank" rel="noopener">链接</a>{% else %}—{% endif %}</td>
|
||||
<td>
|
||||
<button type="button" class="btn-inline btn-edit" data-plan-id="{{ plan.id }}">编辑</button>
|
||||
@@ -60,7 +72,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8">该厂商下暂无配置,在上方表单添加。</td></tr>
|
||||
<tr><td colspan="9">该厂商下暂无配置,在上方表单添加。</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
42
templates/auth/login.html
Normal file
42
templates/auth/login.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>用户登录 - 云服务器价格对比</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/forum.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="forum-topbar">
|
||||
<div class="forum-topbar-inner">
|
||||
<a href="{{ url_for('index') }}" class="forum-brand">VPS Price</a>
|
||||
<nav class="forum-nav">
|
||||
<a href="{{ url_for('forum_index') }}">论坛</a>
|
||||
<a href="{{ url_for('index') }}">价格表</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main class="forum-main">
|
||||
<section class="auth-card">
|
||||
<h1>用户登录</h1>
|
||||
{% if error %}
|
||||
<p class="form-error">{{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('user_login') }}" class="auth-form">
|
||||
<input type="hidden" name="next" value="{{ request.values.get('next', '') }}">
|
||||
<div class="form-group">
|
||||
<label for="username">用户名</label>
|
||||
<input id="username" name="username" type="text" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<input id="password" name="password" type="password" required>
|
||||
</div>
|
||||
<button type="submit">登录</button>
|
||||
</form>
|
||||
<p class="auth-switch">没有账号?<a href="{{ url_for('user_register', next=request.values.get('next', '')) }}">去注册</a></p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
46
templates/auth/register.html
Normal file
46
templates/auth/register.html
Normal file
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>用户注册 - 云服务器价格对比</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/forum.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="forum-topbar">
|
||||
<div class="forum-topbar-inner">
|
||||
<a href="{{ url_for('index') }}" class="forum-brand">VPS Price</a>
|
||||
<nav class="forum-nav">
|
||||
<a href="{{ url_for('forum_index') }}">论坛</a>
|
||||
<a href="{{ url_for('index') }}">价格表</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main class="forum-main">
|
||||
<section class="auth-card">
|
||||
<h1>注册账号</h1>
|
||||
{% if error %}
|
||||
<p class="form-error">{{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('user_register') }}" class="auth-form">
|
||||
<input type="hidden" name="next" value="{{ request.values.get('next', '') }}">
|
||||
<div class="form-group">
|
||||
<label for="username">用户名</label>
|
||||
<input id="username" name="username" type="text" required minlength="3" maxlength="20" placeholder="3-20 位,字母/数字/下划线">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<input id="password" name="password" type="password" required minlength="6">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">确认密码</label>
|
||||
<input id="confirm_password" name="confirm_password" type="password" required minlength="6">
|
||||
</div>
|
||||
<button type="submit">注册并登录</button>
|
||||
</form>
|
||||
<p class="auth-switch">已有账号?<a href="{{ url_for('user_login', next=request.values.get('next', '')) }}">去登录</a></p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
59
templates/forum/index.html
Normal file
59
templates/forum/index.html
Normal file
@@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>论坛 - 云服务器价格对比</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/forum.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="forum-topbar">
|
||||
<div class="forum-topbar-inner">
|
||||
<a href="{{ url_for('index') }}" class="forum-brand">VPS Price</a>
|
||||
<nav class="forum-nav">
|
||||
<a href="{{ url_for('index') }}">价格表</a>
|
||||
<a href="{{ url_for('forum_index') }}" class="active">论坛</a>
|
||||
{% if current_user %}
|
||||
<a href="{{ url_for('forum_post_new') }}">发帖</a>
|
||||
<span class="forum-user">你好,{{ current_user.username }}</span>
|
||||
<a href="{{ url_for('user_logout') }}">退出</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('user_login') }}">登录</a>
|
||||
<a href="{{ url_for('user_register') }}">注册</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="forum-main">
|
||||
<section class="forum-section">
|
||||
<div class="forum-section-header">
|
||||
<h1>社区论坛</h1>
|
||||
{% if current_user %}
|
||||
<a href="{{ url_for('forum_post_new') }}" class="forum-btn">发布新帖</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('user_login', next='/forum/post/new') }}" class="forum-btn">登录后发帖</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if posts %}
|
||||
<ul class="post-list">
|
||||
{% for post in posts %}
|
||||
<li class="post-item">
|
||||
<a href="{{ url_for('forum_post_detail', post_id=post.id) }}" class="post-title">{{ post.title }}</a>
|
||||
<div class="post-meta">
|
||||
<span>作者:{{ post.author_rel.username if post.author_rel else '已注销用户' }}</span>
|
||||
<span>发布时间:{{ post.created_at.strftime('%Y-%m-%d %H:%M') if post.created_at else '' }}</span>
|
||||
<span>评论:{{ post.comments.count() }}</span>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="forum-empty">暂时没有帖子,来发布第一条吧。</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
78
templates/forum/post_detail.html
Normal file
78
templates/forum/post_detail.html
Normal file
@@ -0,0 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ post.title }} - 论坛</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/forum.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="forum-topbar">
|
||||
<div class="forum-topbar-inner">
|
||||
<a href="{{ url_for('index') }}" class="forum-brand">VPS Price</a>
|
||||
<nav class="forum-nav">
|
||||
<a href="{{ url_for('forum_index') }}">论坛首页</a>
|
||||
<a href="{{ url_for('index') }}">价格表</a>
|
||||
{% if current_user %}
|
||||
<span class="forum-user">你好,{{ current_user.username }}</span>
|
||||
<a href="{{ url_for('user_logout') }}">退出</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('user_login', next=request.path) }}">登录</a>
|
||||
<a href="{{ url_for('user_register', next=request.path) }}">注册</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="forum-main">
|
||||
<article class="forum-section post-detail">
|
||||
<h1>{{ post.title }}</h1>
|
||||
<div class="post-meta">
|
||||
<span>作者:{{ post.author_rel.username if post.author_rel else '已注销用户' }}</span>
|
||||
<span>发布时间:{{ post.created_at.strftime('%Y-%m-%d %H:%M') if post.created_at else '' }}</span>
|
||||
<span>评论:{{ comments|length }}</span>
|
||||
</div>
|
||||
<div class="post-content">{{ post.content }}</div>
|
||||
</article>
|
||||
|
||||
<section class="forum-section">
|
||||
<h2>评论</h2>
|
||||
{% if message %}
|
||||
<p class="form-success">{{ message }}</p>
|
||||
{% endif %}
|
||||
{% if error %}
|
||||
<p class="form-error">{{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user %}
|
||||
<form method="post" action="{{ url_for('forum_post_comment', post_id=post.id) }}" class="comment-form">
|
||||
<div class="form-group">
|
||||
<label for="content">写下你的评论</label>
|
||||
<textarea id="content" name="content" required rows="4" minlength="2"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="forum-btn">发布评论</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="forum-empty">请先 <a href="{{ url_for('user_login', next=request.path) }}">登录</a> 后评论。</p>
|
||||
{% endif %}
|
||||
|
||||
{% if comments %}
|
||||
<ul class="comment-list">
|
||||
{% for c in comments %}
|
||||
<li class="comment-item">
|
||||
<div class="comment-meta">
|
||||
<span>{{ c.author_rel.username if c.author_rel else '已注销用户' }}</span>
|
||||
<span>{{ c.created_at.strftime('%Y-%m-%d %H:%M') if c.created_at else '' }}</span>
|
||||
</div>
|
||||
<div class="comment-content">{{ c.content }}</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="forum-empty">还没有评论,欢迎抢沙发。</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
45
templates/forum/post_form.html
Normal file
45
templates/forum/post_form.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>发布帖子 - 云服务器价格对比</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/forum.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="forum-topbar">
|
||||
<div class="forum-topbar-inner">
|
||||
<a href="{{ url_for('index') }}" class="forum-brand">VPS Price</a>
|
||||
<nav class="forum-nav">
|
||||
<a href="{{ url_for('forum_index') }}">论坛首页</a>
|
||||
<span class="forum-user">当前用户:{{ current_user.username }}</span>
|
||||
<a href="{{ url_for('user_logout') }}">退出</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="forum-main">
|
||||
<section class="forum-section">
|
||||
<h1>发布新帖</h1>
|
||||
{% if error %}
|
||||
<p class="form-error">{{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('forum_post_new') }}" class="post-form">
|
||||
<div class="form-group">
|
||||
<label for="title">标题</label>
|
||||
<input id="title" name="title" type="text" required maxlength="160" value="{{ title_val or '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="content">正文</label>
|
||||
<textarea id="content" name="content" required rows="10">{{ content_val or '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="forum-btn">发布</button>
|
||||
<a href="{{ url_for('forum_index') }}" class="forum-btn forum-btn-ghost">取消</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user