哈哈
This commit is contained in:
620
app.py
620
app.py
@@ -1,10 +1,11 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""云服务器价格对比 - Flask 应用"""
|
"""云服务器价格对比 - Flask 应用"""
|
||||||
import io
|
import io
|
||||||
|
from datetime import datetime
|
||||||
from urllib.parse import urlencode
|
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
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text, func
|
||||||
from config import Config
|
from config import Config
|
||||||
from extensions import db
|
from extensions import db
|
||||||
from openpyxl import Workbook
|
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)
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||||||
db.init_app(app)
|
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():
|
def _ensure_mysql_columns():
|
||||||
@@ -51,10 +52,37 @@ def _ensure_mysql_columns():
|
|||||||
pass # 表不存在或非 MySQL 时忽略
|
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():
|
with app.app_context():
|
||||||
db.create_all()
|
db.create_all()
|
||||||
_ensure_mysql_columns()
|
_ensure_mysql_columns()
|
||||||
|
_ensure_price_history_baseline()
|
||||||
|
|
||||||
ADMIN_PASSWORD = app.config["ADMIN_PASSWORD"]
|
ADMIN_PASSWORD = app.config["ADMIN_PASSWORD"]
|
||||||
SITE_URL = app.config["SITE_URL"]
|
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):
|
def admin_required(f):
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
@@ -77,6 +232,16 @@ def admin_required(f):
|
|||||||
return wrapped
|
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("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
plans = VPSPlan.query.order_by(VPSPlan.provider, VPSPlan.price_cny).all()
|
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])
|
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 ----------
|
# ---------- SEO ----------
|
||||||
@app.route("/sitemap.xml")
|
@app.route("/sitemap.xml")
|
||||||
def sitemap():
|
def sitemap():
|
||||||
@@ -106,6 +397,11 @@ def sitemap():
|
|||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>{url}/forum</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
</urlset>'''
|
</urlset>'''
|
||||||
resp = make_response(xml)
|
resp = make_response(xml)
|
||||||
resp.mimetype = "application/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")
|
@app.route("/admin")
|
||||||
@admin_required
|
@admin_required
|
||||||
def admin_dashboard():
|
def admin_dashboard():
|
||||||
providers = Provider.query.order_by(Provider.name).all()
|
providers = Provider.query.order_by(Provider.name).all()
|
||||||
plans = VPSPlan.query.order_by(VPSPlan.provider, VPSPlan.price_cny).all()
|
plans = VPSPlan.query.order_by(VPSPlan.provider, VPSPlan.price_cny).all()
|
||||||
|
plan_trends = _build_plan_trend_map(plans)
|
||||||
return render_template(
|
return render_template(
|
||||||
"admin/dashboard.html",
|
"admin/dashboard.html",
|
||||||
providers=providers,
|
providers=providers,
|
||||||
plans=plans,
|
plans=plans,
|
||||||
|
plan_trends=plan_trends,
|
||||||
country_tags=COUNTRY_TAGS,
|
country_tags=COUNTRY_TAGS,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -210,10 +532,12 @@ def admin_provider_detail(provider_id):
|
|||||||
(VPSPlan.provider_id == provider_id) | (VPSPlan.provider == provider.name)
|
(VPSPlan.provider_id == provider_id) | (VPSPlan.provider == provider.name)
|
||||||
).order_by(VPSPlan.price_cny.asc(), VPSPlan.name).all()
|
).order_by(VPSPlan.price_cny.asc(), VPSPlan.name).all()
|
||||||
providers = Provider.query.order_by(Provider.name).all()
|
providers = Provider.query.order_by(Provider.name).all()
|
||||||
|
plan_trends = _build_plan_trend_map(plans)
|
||||||
return render_template(
|
return render_template(
|
||||||
"admin/provider_detail.html",
|
"admin/provider_detail.html",
|
||||||
provider=provider,
|
provider=provider,
|
||||||
plans=plans,
|
plans=plans,
|
||||||
|
plan_trends=plan_trends,
|
||||||
providers=providers,
|
providers=providers,
|
||||||
country_tags=COUNTRY_TAGS,
|
country_tags=COUNTRY_TAGS,
|
||||||
)
|
)
|
||||||
@@ -350,6 +674,8 @@ def _save_plan(plan):
|
|||||||
plan.official_url = official_url
|
plan.official_url = official_url
|
||||||
plan.countries = countries
|
plan.countries = countries
|
||||||
|
|
||||||
|
db.session.flush()
|
||||||
|
_record_price_history(plan, source="manual")
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
# 若从厂商详情页进入添加,保存后返回该厂商详情
|
# 若从厂商详情页进入添加,保存后返回该厂商详情
|
||||||
from_provider_id = request.form.get("from_provider_id", type=int)
|
from_provider_id = request.form.get("from_provider_id", type=int)
|
||||||
@@ -362,6 +688,7 @@ def _save_plan(plan):
|
|||||||
@admin_required
|
@admin_required
|
||||||
def admin_plan_delete(plan_id):
|
def admin_plan_delete(plan_id):
|
||||||
plan = VPSPlan.query.get_or_404(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.delete(plan)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return redirect(url_for("admin_dashboard"))
|
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):
|
def _num(v):
|
||||||
if v is None or v == "":
|
if v is None or v == "":
|
||||||
return None
|
return None
|
||||||
@@ -448,6 +754,149 @@ def _float(v):
|
|||||||
return None
|
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"])
|
@app.route("/admin/import", methods=["GET", "POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def admin_import():
|
def admin_import():
|
||||||
@@ -482,66 +931,101 @@ def admin_import():
|
|||||||
if not parsed:
|
if not parsed:
|
||||||
return render_template("admin/import.html", error="文件中没有有效数据行")
|
return render_template("admin/import.html", error="文件中没有有效数据行")
|
||||||
plans = VPSPlan.query.all()
|
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:
|
for row in parsed:
|
||||||
provider_name = (row.get("厂商") or "").strip()
|
key = _row_identity_key(row)
|
||||||
|
provider_name = key[0]
|
||||||
if not provider_name:
|
if not provider_name:
|
||||||
continue
|
continue
|
||||||
found = False
|
if key in seen_row_keys:
|
||||||
for p in plans:
|
continue
|
||||||
if _plan_match(p, row):
|
seen_row_keys.add(key)
|
||||||
found = True
|
matched = plan_index.get(key)
|
||||||
break
|
if not matched:
|
||||||
if not found:
|
preview_items.append({
|
||||||
to_add.append(row)
|
"action": "add",
|
||||||
session["import_preview"] = to_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"))
|
return redirect(url_for("admin_import_preview"))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/admin/import/preview", methods=["GET", "POST"])
|
@app.route("/admin/import/preview", methods=["GET", "POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def admin_import_preview():
|
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":
|
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")
|
selected = request.form.getlist("row_index")
|
||||||
if not selected:
|
if not selected:
|
||||||
to_add = session.get("import_preview") or []
|
return render_template(
|
||||||
return render_template("admin/import_preview.html", rows=list(enumerate(to_add)), error="请至少勾选一行")
|
"admin/import_preview.html",
|
||||||
to_add = session.get("import_preview") or []
|
rows=list(enumerate(preview_items)),
|
||||||
indices = set(int(x) for x in selected if x.isdigit())
|
add_count=add_count,
|
||||||
for i in indices:
|
update_count=update_count,
|
||||||
if i < 0 or i >= len(to_add):
|
error="请至少勾选一行",
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
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()
|
db.session.commit()
|
||||||
session.pop("import_preview", None)
|
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__":
|
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__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
from app import app, db
|
from app import app, db
|
||||||
from models import VPSPlan
|
from models import VPSPlan, PriceHistory
|
||||||
|
|
||||||
# 默认官网链接(可按方案自定义)
|
# 默认官网链接(可按方案自定义)
|
||||||
DEFAULT_URLS = {
|
DEFAULT_URLS = {
|
||||||
@@ -54,6 +54,19 @@ def main():
|
|||||||
)
|
)
|
||||||
db.session.add(plan)
|
db.session.add(plan)
|
||||||
db.session.commit()
|
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))
|
print("已导入 %d 条初始方案。" % len(INITIAL_PLANS))
|
||||||
else:
|
else:
|
||||||
print("数据库中已有数据,跳过导入。若要重新初始化请删除 vps_price.db 后重试。")
|
print("数据库中已有数据,跳过导入。若要重新初始化请删除 vps_price.db 后重试。")
|
||||||
|
|||||||
88
models.py
88
models.py
@@ -1,5 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""数据库模型"""
|
"""数据库模型"""
|
||||||
|
from datetime import datetime
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
from extensions import db
|
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")
|
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):
|
class VPSPlan(db.Model):
|
||||||
"""云服务器配置/方案(属于某厂商)"""
|
"""云服务器配置/方案(属于某厂商)"""
|
||||||
__tablename__ = "vps_plans"
|
__tablename__ = "vps_plans"
|
||||||
@@ -33,6 +69,13 @@ class VPSPlan(db.Model):
|
|||||||
currency = db.Column(db.String(8), default="CNY")
|
currency = db.Column(db.String(8), default="CNY")
|
||||||
official_url = db.Column(db.String(512), nullable=True) # 该配置详情页,可覆盖厂商默认
|
official_url = db.Column(db.String(512), nullable=True) # 该配置详情页,可覆盖厂商默认
|
||||||
countries = db.Column(db.String(255), nullable=True, index=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
|
@property
|
||||||
def provider_name(self):
|
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 "",
|
"official_url": self.official_url or (self.provider_rel.official_url if self.provider_rel else "") or "",
|
||||||
"countries": self.countries 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);
|
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 {
|
.admin-table .btn-delete {
|
||||||
background: none;
|
background: none;
|
||||||
border: 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
|
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() {
|
function init() {
|
||||||
fetchData();
|
fetchData();
|
||||||
@@ -213,7 +242,9 @@
|
|||||||
var range = filters.price.split('-');
|
var range = filters.price.split('-');
|
||||||
var min = parseFloat(range[0]);
|
var min = parseFloat(range[0]);
|
||||||
var max = parseFloat(range[1]);
|
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;
|
if (!currentSort.column) return plans;
|
||||||
|
|
||||||
return plans.slice().sort(function(a, b) {
|
return plans.slice().sort(function(a, b) {
|
||||||
var aVal = a[currentSort.column];
|
var aVal;
|
||||||
var bVal = b[currentSort.column];
|
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') {
|
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||||
return currentSort.direction === 'asc' ? aVal - bVal : bVal - aVal;
|
return currentSort.direction === 'asc' ? aVal - bVal : bVal - aVal;
|
||||||
@@ -250,9 +290,8 @@
|
|||||||
|
|
||||||
function createTableRow(plan) {
|
function createTableRow(plan) {
|
||||||
var tr = document.createElement('tr');
|
var tr = document.createElement('tr');
|
||||||
|
var currentPrice = getPriceValue(plan, filters.currency);
|
||||||
var price = convertPrice(plan.price_cny, filters.currency);
|
var displayPrice = currentPrice ? currentPrice.symbol + currentPrice.value.toFixed(2) : '—';
|
||||||
var priceSymbol = filters.currency === 'CNY' ? '¥' : '$';
|
|
||||||
|
|
||||||
tr.innerHTML =
|
tr.innerHTML =
|
||||||
'<td>' + escapeHtml(plan.provider) + '</td>' +
|
'<td>' + escapeHtml(plan.provider) + '</td>' +
|
||||||
@@ -263,7 +302,7 @@
|
|||||||
'<td>' + plan.storage_gb + ' GB</td>' +
|
'<td>' + plan.storage_gb + ' GB</td>' +
|
||||||
'<td>' + (plan.bandwidth_mbps ? plan.bandwidth_mbps + ' Mbps' : '-') + '</td>' +
|
'<td>' + (plan.bandwidth_mbps ? plan.bandwidth_mbps + ' Mbps' : '-') + '</td>' +
|
||||||
'<td>' + plan.traffic + '</td>' +
|
'<td>' + plan.traffic + '</td>' +
|
||||||
'<td class="col-price">' + priceSymbol + price + '</td>' +
|
'<td class="col-price">' + displayPrice + '</td>' +
|
||||||
'<td class="col-link">' +
|
'<td class="col-link">' +
|
||||||
'<a href="' + escapeHtml(plan.official_url) + '" target="_blank" rel="noopener" class="btn-link">访问</a>' +
|
'<a href="' + escapeHtml(plan.official_url) + '" target="_blank" rel="noopener" class="btn-link">访问</a>' +
|
||||||
'</td>';
|
'</td>';
|
||||||
@@ -271,12 +310,6 @@
|
|||||||
return tr;
|
return tr;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 工具函数 ====================
|
|
||||||
function convertPrice(priceCNY, currency) {
|
|
||||||
var converted = priceCNY * exchangeRates[currency];
|
|
||||||
return converted.toFixed(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
var div = document.createElement('div');
|
var div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
<th>内存</th>
|
<th>内存</th>
|
||||||
<th>流量</th>
|
<th>流量</th>
|
||||||
<th>月付</th>
|
<th>月付</th>
|
||||||
|
<th>涨跌趋势</th>
|
||||||
<th>官网链接</th>
|
<th>官网链接</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -71,6 +72,17 @@
|
|||||||
<td>{{ plan.memory_gb ~ ' GB' if plan.memory_gb is not none else '—' }}</td>
|
<td>{{ plan.memory_gb ~ ' GB' if plan.memory_gb is not none else '—' }}</td>
|
||||||
<td>{{ plan.traffic or '—' }}</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>{% 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>{% if plan.official_url %}<a href="{{ plan.official_url }}" target="_blank" rel="noopener">链接</a>{% else %}—{% endif %}</td>
|
||||||
<td>
|
<td>
|
||||||
<button type="button" class="btn-inline btn-edit" data-plan-id="{{ plan.id }}">编辑</button>
|
<button type="button" class="btn-inline btn-edit" data-plan-id="{{ plan.id }}">编辑</button>
|
||||||
@@ -81,7 +93,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="9">暂无配置。</td></tr>
|
<tr><td colspan="10">暂无配置。</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main class="admin-main">
|
<main class="admin-main">
|
||||||
<p class="hint">上传与导出格式一致的 .xlsx,系统会与现有数据比对,<strong>仅将「厂商+配置+价格」在库中不存在的行</strong>列为待确认;确认后才会写入数据库并展示。</p>
|
<p class="hint">上传与导出格式一致的 .xlsx,系统会与现有数据比对,自动识别<strong>新增项</strong>和<strong>可更新项</strong>;在预览页勾选确认后再写入数据库。</p>
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<p class="error">{{ error }}</p>
|
<p class="error">{{ error }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<input type="file" id="file" name="file" accept=".xlsx" required>
|
<input type="file" id="file" name="file" accept=".xlsx" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit">上传并预览待新增</button>
|
<button type="submit">上传并预览待处理项</button>
|
||||||
<a href="{{ url_for('admin_dashboard') }}" class="btn-cancel">取消</a>
|
<a href="{{ url_for('admin_dashboard') }}" class="btn-cancel">取消</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -17,18 +17,19 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main class="admin-main">
|
<main class="admin-main">
|
||||||
<p class="hint">以下为与现有数据不重复的配置,勾选需要写入的项后点击「确认导入」即可正式入库并展示。</p>
|
<p class="hint">本次解析结果:新增 {{ add_count or 0 }} 条,可更新 {{ update_count or 0 }} 条。勾选后点击「确认导入」执行写入。</p>
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<p class="error">{{ error }}</p>
|
<p class="error">{{ error }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not rows %}
|
{% if not rows %}
|
||||||
<p>没有待新增数据(可能全部已存在)。<a href="{{ url_for('admin_import') }}">重新上传</a></p>
|
<p>没有待处理数据(可能全部已存在且无变化)。<a href="{{ url_for('admin_import') }}">重新上传</a></p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="{{ url_for('admin_import_preview') }}">
|
<form method="post" action="{{ url_for('admin_import_preview') }}">
|
||||||
<table class="admin-table">
|
<table class="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th><input type="checkbox" id="check-all" checked></th>
|
<th><input type="checkbox" id="check-all" checked></th>
|
||||||
|
<th>动作</th>
|
||||||
<th>厂商</th>
|
<th>厂商</th>
|
||||||
<th>国家</th>
|
<th>国家</th>
|
||||||
<th>vCPU</th>
|
<th>vCPU</th>
|
||||||
@@ -37,13 +38,16 @@
|
|||||||
<th>流量</th>
|
<th>流量</th>
|
||||||
<th>月付¥</th>
|
<th>月付¥</th>
|
||||||
<th>月付$</th>
|
<th>月付$</th>
|
||||||
|
<th>变更</th>
|
||||||
<th>配置官网</th>
|
<th>配置官网</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for idx, row in rows %}
|
{% for idx, item in rows %}
|
||||||
|
{% set row = item.get('row', {}) %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><input type="checkbox" name="row_index" value="{{ idx }}" checked></td>
|
<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('国家') or '—' }}</td>
|
<td>{{ row.get('国家') or '—' }}</td>
|
||||||
<td>{{ row.get('vCPU') 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>{{ 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>
|
<td>{{ (row.get('配置官网') or '')[:30] }}{% if (row.get('配置官网') or '')|length > 30 %}…{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
<th>内存</th>
|
<th>内存</th>
|
||||||
<th>流量</th>
|
<th>流量</th>
|
||||||
<th>月付</th>
|
<th>月付</th>
|
||||||
|
<th>涨跌趋势</th>
|
||||||
<th>官网链接</th>
|
<th>官网链接</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -50,6 +51,17 @@
|
|||||||
<td>{{ plan.memory_gb ~ ' GB' if plan.memory_gb is not none else '—' }}</td>
|
<td>{{ plan.memory_gb ~ ' GB' if plan.memory_gb is not none else '—' }}</td>
|
||||||
<td>{{ plan.traffic or '—' }}</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>{% 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>{% if plan.official_url %}<a href="{{ plan.official_url }}" target="_blank" rel="noopener">链接</a>{% else %}—{% endif %}</td>
|
||||||
<td>
|
<td>
|
||||||
<button type="button" class="btn-inline btn-edit" data-plan-id="{{ plan.id }}">编辑</button>
|
<button type="button" class="btn-inline btn-edit" data-plan-id="{{ plan.id }}">编辑</button>
|
||||||
@@ -60,7 +72,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="8">该厂商下暂无配置,在上方表单添加。</td></tr>
|
<tr><td colspan="9">该厂商下暂无配置,在上方表单添加。</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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