This commit is contained in:
ddrwode
2026-02-09 22:36:32 +08:00
parent cb849613a9
commit 5fe60fdd57
15 changed files with 1288 additions and 89 deletions

620
app.py
View File

@@ -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__":

View File

@@ -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 后重试。")

View File

@@ -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)

View File

@@ -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
View 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);
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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
View 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>

View 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>

View 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>

View 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>

View 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>