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 -*-
"""云服务器价格对比 - Flask 应用"""
import io
from datetime import datetime
from urllib.parse import urlencode
from flask import Flask, render_template, jsonify, request, redirect, url_for, session, send_file
from werkzeug.middleware.proxy_fix import ProxyFix
from sqlalchemy import text
from sqlalchemy import text, func
from config import Config
from extensions import db
from openpyxl import Workbook
@@ -16,7 +17,7 @@ app.config.from_object(Config)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
db.init_app(app)
from models import VPSPlan, Provider # noqa: E402
from models import VPSPlan, Provider, PriceHistory, User, ForumPost, ForumComment # noqa: E402
def _ensure_mysql_columns():
@@ -51,10 +52,37 @@ def _ensure_mysql_columns():
pass # 表不存在或非 MySQL 时忽略
def _ensure_price_history_baseline():
"""为历史数据补首条价格快照,便于后续计算涨跌。"""
try:
missing = (
db.session.query(VPSPlan)
.outerjoin(PriceHistory, PriceHistory.plan_id == VPSPlan.id)
.filter(PriceHistory.id.is_(None))
.all()
)
if not missing:
return
for p in missing:
if p.price_cny is None and p.price_usd is None:
continue
db.session.add(PriceHistory(
plan_id=p.id,
price_cny=p.price_cny,
price_usd=p.price_usd,
currency=(p.currency or "CNY"),
source="bootstrap",
))
db.session.commit()
except Exception:
db.session.rollback()
# 启动时自动创建表(若不存在),并为已有表补列
with app.app_context():
db.create_all()
_ensure_mysql_columns()
_ensure_price_history_baseline()
ADMIN_PASSWORD = app.config["ADMIN_PASSWORD"]
SITE_URL = app.config["SITE_URL"]
@@ -66,6 +94,133 @@ COUNTRY_TAGS = [
"德国", "英国", "法国", "荷兰", "加拿大", "澳大利亚", "印度", "其他",
]
PRICE_SOURCE_LABELS = {
"manual": "手工编辑",
"import": "Excel 导入",
"bootstrap": "基线",
}
def _get_current_user():
user_id = session.get("user_id")
if not user_id:
return None
user = User.query.get(user_id)
if not user:
session.pop("user_id", None)
return user
def _is_valid_username(username):
if not username:
return False
if len(username) < 3 or len(username) > 20:
return False
return all(ch.isalnum() or ch == "_" for ch in username)
def _safe_next_url(default_endpoint):
nxt = (request.values.get("next") or "").strip()
if nxt.startswith("/") and not nxt.startswith("//"):
return nxt
return url_for(default_endpoint)
@app.context_processor
def inject_global_user():
return {
"current_user": _get_current_user(),
"admin_logged_in": bool(session.get("admin_logged_in")),
}
def _currency_symbol(currency):
return "¥" if (currency or "CNY").upper() == "CNY" else "$"
def _format_money(currency, value):
return "{}{:.2f}".format(_currency_symbol(currency), float(value))
def _format_history_time(dt):
return dt.strftime("%Y-%m-%d %H:%M") if dt else ""
def _pick_price_pair(latest, previous=None):
if previous is None:
if latest.price_cny is not None:
return "CNY", float(latest.price_cny), None
if latest.price_usd is not None:
return "USD", float(latest.price_usd), None
return None, None, None
if latest.price_cny is not None and previous.price_cny is not None:
return "CNY", float(latest.price_cny), float(previous.price_cny)
if latest.price_usd is not None and previous.price_usd is not None:
return "USD", float(latest.price_usd), float(previous.price_usd)
return None, None, None
def _build_price_trend(latest, previous=None):
currency, current_value, previous_value = _pick_price_pair(latest, previous)
if currency is None or current_value is None:
return None
source = PRICE_SOURCE_LABELS.get(latest.source or "", latest.source or "未知来源")
meta = "当前 {} · {} · {}".format(
_format_money(currency, current_value),
_format_history_time(latest.captured_at),
source,
)
if previous_value is None:
return {
"direction": "new",
"delta_text": "首次记录",
"meta_text": meta,
}
diff = current_value - previous_value
if abs(diff) < 1e-9:
return {
"direction": "flat",
"delta_text": "→ 持平",
"meta_text": meta,
}
direction = "up" if diff > 0 else "down"
arrow = "" if diff > 0 else ""
sign = "+" if diff > 0 else "-"
delta_text = "{} {}{}{:,.2f}".format(arrow, sign, _currency_symbol(currency), abs(diff))
if abs(previous_value) > 1e-9:
pct = diff / previous_value * 100
delta_text += " ({:+.2f}%)".format(pct)
return {
"direction": direction,
"delta_text": delta_text,
"meta_text": meta,
}
def _build_plan_trend_map(plans):
plan_ids = [p.id for p in plans if p.id is not None]
if not plan_ids:
return {}
rows = (
PriceHistory.query
.filter(PriceHistory.plan_id.in_(plan_ids))
.order_by(PriceHistory.plan_id.asc(), PriceHistory.captured_at.desc(), PriceHistory.id.desc())
.all()
)
grouped = {}
for row in rows:
bucket = grouped.setdefault(row.plan_id, [])
if len(bucket) < 2:
bucket.append(row)
result = {}
for plan_id, bucket in grouped.items():
latest = bucket[0] if bucket else None
previous = bucket[1] if len(bucket) > 1 else None
trend = _build_price_trend(latest, previous) if latest else None
if trend:
result[plan_id] = trend
return result
def admin_required(f):
from functools import wraps
@@ -77,6 +232,16 @@ def admin_required(f):
return wrapped
def user_login_required(f):
from functools import wraps
@wraps(f)
def wrapped(*args, **kwargs):
if not _get_current_user():
return redirect(url_for("user_login", next=request.path))
return f(*args, **kwargs)
return wrapped
@app.route("/")
def index():
plans = VPSPlan.query.order_by(VPSPlan.provider, VPSPlan.price_cny).all()
@@ -94,6 +259,132 @@ def api_plans():
return jsonify([p.to_dict() for p in plans])
# ---------- 前台用户与论坛 ----------
@app.route("/register", methods=["GET", "POST"])
def user_register():
if _get_current_user():
return redirect(url_for("forum_index"))
error = None
if request.method == "POST":
username = (request.form.get("username") or "").strip()
password = request.form.get("password") or ""
confirm_password = request.form.get("confirm_password") or ""
if not _is_valid_username(username):
error = "用户名需为 3-20 位,仅支持字母、数字、下划线"
elif len(password) < 6:
error = "密码至少 6 位"
elif password != confirm_password:
error = "两次输入的密码不一致"
elif User.query.filter(func.lower(User.username) == username.lower()).first():
error = "用户名已存在"
else:
user = User(username=username)
user.set_password(password)
user.last_login_at = datetime.utcnow()
db.session.add(user)
db.session.commit()
session["user_id"] = user.id
return redirect(_safe_next_url("forum_index"))
return render_template("auth/register.html", error=error)
@app.route("/login", methods=["GET", "POST"])
def user_login():
if _get_current_user():
return redirect(url_for("forum_index"))
error = None
if request.method == "POST":
username = (request.form.get("username") or "").strip()
password = request.form.get("password") or ""
user = User.query.filter(func.lower(User.username) == username.lower()).first()
if not user or not user.check_password(password):
error = "用户名或密码错误"
else:
user.last_login_at = datetime.utcnow()
db.session.commit()
session["user_id"] = user.id
return redirect(_safe_next_url("forum_index"))
return render_template("auth/login.html", error=error)
@app.route("/logout")
def user_logout():
session.pop("user_id", None)
return redirect(url_for("forum_index"))
@app.route("/forum")
def forum_index():
posts = ForumPost.query.order_by(ForumPost.created_at.desc(), ForumPost.id.desc()).all()
return render_template("forum/index.html", posts=posts)
@app.route("/forum/post/new", methods=["GET", "POST"])
@user_login_required
def forum_post_new():
user = _get_current_user()
error = None
title = ""
content = ""
if request.method == "POST":
title = (request.form.get("title") or "").strip()
content = (request.form.get("content") or "").strip()
if len(title) < 5:
error = "标题至少 5 个字符"
elif len(title) > 160:
error = "标题不能超过 160 个字符"
elif len(content) < 10:
error = "内容至少 10 个字符"
else:
post = ForumPost(
user_id=user.id,
title=title,
content=content,
)
db.session.add(post)
db.session.commit()
return redirect(url_for("forum_post_detail", post_id=post.id))
return render_template("forum/post_form.html", error=error, title_val=title, content_val=content)
@app.route("/forum/post/<int:post_id>")
def forum_post_detail(post_id):
post = ForumPost.query.get_or_404(post_id)
comments = (
ForumComment.query
.filter_by(post_id=post.id)
.order_by(ForumComment.created_at.asc(), ForumComment.id.asc())
.all()
)
return render_template(
"forum/post_detail.html",
post=post,
comments=comments,
message=request.args.get("msg") or "",
error=request.args.get("error") or "",
)
@app.route("/forum/post/<int:post_id>/comment", methods=["POST"])
@user_login_required
def forum_post_comment(post_id):
post = ForumPost.query.get_or_404(post_id)
user = _get_current_user()
content = (request.form.get("content") or "").strip()
if len(content) < 2:
return redirect(url_for("forum_post_detail", post_id=post.id, error="评论至少 2 个字符"))
comment = ForumComment(
post_id=post.id,
user_id=user.id,
content=content,
)
db.session.add(comment)
db.session.commit()
return redirect(url_for("forum_post_detail", post_id=post.id, msg="评论发布成功"))
# ---------- SEO ----------
@app.route("/sitemap.xml")
def sitemap():
@@ -106,6 +397,11 @@ def sitemap():
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>{url}/forum</loc>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
</urlset>'''
resp = make_response(xml)
resp.mimetype = "application/xml"
@@ -164,15 +460,41 @@ def admin_api_plan(plan_id):
})
@app.route("/admin/api/plan/<int:plan_id>/price-history")
@admin_required
def admin_api_plan_price_history(plan_id):
plan = VPSPlan.query.get_or_404(plan_id)
rows = (
PriceHistory.query
.filter_by(plan_id=plan.id)
.order_by(PriceHistory.captured_at.desc(), PriceHistory.id.desc())
.limit(30)
.all()
)
return jsonify([
{
"id": r.id,
"price_cny": float(r.price_cny) if r.price_cny is not None else None,
"price_usd": float(r.price_usd) if r.price_usd is not None else None,
"currency": r.currency or "CNY",
"source": r.source or "",
"captured_at": r.captured_at.isoformat() if r.captured_at else "",
}
for r in rows
])
@app.route("/admin")
@admin_required
def admin_dashboard():
providers = Provider.query.order_by(Provider.name).all()
plans = VPSPlan.query.order_by(VPSPlan.provider, VPSPlan.price_cny).all()
plan_trends = _build_plan_trend_map(plans)
return render_template(
"admin/dashboard.html",
providers=providers,
plans=plans,
plan_trends=plan_trends,
country_tags=COUNTRY_TAGS,
)
@@ -210,10 +532,12 @@ def admin_provider_detail(provider_id):
(VPSPlan.provider_id == provider_id) | (VPSPlan.provider == provider.name)
).order_by(VPSPlan.price_cny.asc(), VPSPlan.name).all()
providers = Provider.query.order_by(Provider.name).all()
plan_trends = _build_plan_trend_map(plans)
return render_template(
"admin/provider_detail.html",
provider=provider,
plans=plans,
plan_trends=plan_trends,
providers=providers,
country_tags=COUNTRY_TAGS,
)
@@ -350,6 +674,8 @@ def _save_plan(plan):
plan.official_url = official_url
plan.countries = countries
db.session.flush()
_record_price_history(plan, source="manual")
db.session.commit()
# 若从厂商详情页进入添加,保存后返回该厂商详情
from_provider_id = request.form.get("from_provider_id", type=int)
@@ -362,6 +688,7 @@ def _save_plan(plan):
@admin_required
def admin_plan_delete(plan_id):
plan = VPSPlan.query.get_or_404(plan_id)
PriceHistory.query.filter_by(plan_id=plan_id).delete()
db.session.delete(plan)
db.session.commit()
return redirect(url_for("admin_dashboard"))
@@ -409,27 +736,6 @@ def admin_export_excel():
)
def _plan_match(plan, row):
"""判断 DB 中的 plan 与导入行是否同一配置(厂商+配置+价格)。"""
def eq(a, b):
if a is None and b in (None, ""):
return True
if a is None or b in (None, ""):
return a == b
return a == b
return (
plan.provider_name == (row.get("厂商") or "").strip()
and eq(plan.vcpu, _num(row.get("vCPU")))
and eq(plan.memory_gb, _num(row.get("内存GB")))
and eq(plan.storage_gb, _num(row.get("存储GB")))
and eq(plan.bandwidth_mbps, _num(row.get("带宽Mbps")))
and (plan.countries or "").strip() == (row.get("国家") or "").strip()
and eq(plan.price_cny, _float(row.get("月付人民币")))
and eq(plan.price_usd, _float(row.get("月付美元")))
)
def _num(v):
if v is None or v == "":
return None
@@ -448,6 +754,149 @@ def _float(v):
return None
def _opt_text(v):
if v is None:
return None
s = str(v).strip()
return s or None
def _safe_str(v):
if v is None:
return ""
return str(v).strip()
def _eq_optional(a, b):
if a is None and b is None:
return True
if a is None or b is None:
return False
if isinstance(a, float) or isinstance(b, float):
return abs(float(a) - float(b)) < 1e-9
return a == b
def _record_price_history(plan, source):
if plan is None:
return
if plan.price_cny is None and plan.price_usd is None:
return
if plan.id is None:
db.session.flush()
latest = (
PriceHistory.query
.filter_by(plan_id=plan.id)
.order_by(PriceHistory.captured_at.desc(), PriceHistory.id.desc())
.first()
)
currency = _opt_text(plan.currency) or "CNY"
if latest:
same_currency = _safe_str(latest.currency).upper() == _safe_str(currency).upper()
if same_currency and _eq_optional(latest.price_cny, plan.price_cny) and _eq_optional(latest.price_usd, plan.price_usd):
return
db.session.add(PriceHistory(
plan_id=plan.id,
price_cny=plan.price_cny,
price_usd=plan.price_usd,
currency=currency,
source=source,
))
def _display_val(v):
if v is None or v == "":
return ""
if isinstance(v, float):
s = "{:.2f}".format(v).rstrip("0").rstrip(".")
return s if s else "0"
return str(v)
def _row_identity_key(row):
return (
_safe_str(row.get("厂商")),
_num(row.get("vCPU")),
_num(row.get("内存GB")),
_num(row.get("存储GB")),
_num(row.get("带宽Mbps")),
_safe_str(row.get("国家")),
_safe_str(row.get("流量")),
)
def _plan_identity_key(plan):
return (
_safe_str(plan.provider_name),
plan.vcpu,
plan.memory_gb,
plan.storage_gb,
plan.bandwidth_mbps,
_safe_str(plan.countries),
_safe_str(plan.traffic),
)
def _plan_diff(plan, row):
"""返回导入行相对于现有 plan 的差异列表。"""
fields = [
("国家", "countries", _opt_text(row.get("国家"))),
("vCPU", "vcpu", _num(row.get("vCPU"))),
("内存GB", "memory_gb", _num(row.get("内存GB"))),
("存储GB", "storage_gb", _num(row.get("存储GB"))),
("带宽Mbps", "bandwidth_mbps", _num(row.get("带宽Mbps"))),
("流量", "traffic", _opt_text(row.get("流量"))),
("月付人民币", "price_cny", _float(row.get("月付人民币"))),
("月付美元", "price_usd", _float(row.get("月付美元"))),
("货币", "currency", _opt_text(row.get("货币")) or "CNY"),
("配置官网", "official_url", _opt_text(row.get("配置官网"))),
]
diffs = []
for label, attr, new_value in fields:
old_value = getattr(plan, attr)
if not _eq_optional(old_value, new_value):
diffs.append({
"label": label,
"old": old_value,
"new": new_value,
"old_display": _display_val(old_value),
"new_display": _display_val(new_value),
})
return diffs
def _upsert_provider_from_row(row):
provider_name = _safe_str(row.get("厂商"))
if not provider_name:
return None
imported_provider_url = _opt_text(row.get("厂商官网"))
provider = Provider.query.filter_by(name=provider_name).first()
if not provider:
provider = Provider(name=provider_name, official_url=imported_provider_url)
db.session.add(provider)
db.session.flush()
elif imported_provider_url and provider.official_url != imported_provider_url:
provider.official_url = imported_provider_url
return provider
def _fill_plan_from_row(plan, row, provider):
plan.provider_id = provider.id
plan.provider = provider.name
plan.region = None
plan.name = None
plan.vcpu = _num(row.get("vCPU"))
plan.memory_gb = _num(row.get("内存GB"))
plan.storage_gb = _num(row.get("存储GB"))
plan.bandwidth_mbps = _num(row.get("带宽Mbps"))
plan.traffic = _opt_text(row.get("流量"))
plan.price_cny = _float(row.get("月付人民币"))
plan.price_usd = _float(row.get("月付美元"))
plan.currency = _opt_text(row.get("货币")) or "CNY"
plan.official_url = _opt_text(row.get("配置官网"))
plan.countries = _opt_text(row.get("国家"))
@app.route("/admin/import", methods=["GET", "POST"])
@admin_required
def admin_import():
@@ -482,66 +931,101 @@ def admin_import():
if not parsed:
return render_template("admin/import.html", error="文件中没有有效数据行")
plans = VPSPlan.query.all()
to_add = []
plan_index = {}
for p in plans:
key = _plan_identity_key(p)
if key not in plan_index:
plan_index[key] = p
seen_row_keys = set()
preview_items = []
for row in parsed:
provider_name = (row.get("厂商") or "").strip()
key = _row_identity_key(row)
provider_name = key[0]
if not provider_name:
continue
found = False
for p in plans:
if _plan_match(p, row):
found = True
break
if not found:
to_add.append(row)
session["import_preview"] = to_add
if key in seen_row_keys:
continue
seen_row_keys.add(key)
matched = plan_index.get(key)
if not matched:
preview_items.append({
"action": "add",
"row": row,
"changes": [],
"provider_url_changed": False,
})
continue
changes = _plan_diff(matched, row)
imported_provider_url = _opt_text(row.get("厂商官网"))
old_provider_url = _opt_text(matched.provider_rel.official_url if matched.provider_rel else None)
provider_url_changed = bool(imported_provider_url and imported_provider_url != old_provider_url)
if changes or provider_url_changed:
preview_items.append({
"action": "update",
"plan_id": matched.id,
"row": row,
"changes": changes,
"provider_url_changed": provider_url_changed,
"provider_url_old": old_provider_url,
"provider_url_new": imported_provider_url,
})
session["import_preview"] = preview_items
return redirect(url_for("admin_import_preview"))
@app.route("/admin/import/preview", methods=["GET", "POST"])
@admin_required
def admin_import_preview():
to_add = session.get("import_preview") or []
preview_items = session.get("import_preview") or []
add_count = sum(1 for x in preview_items if x.get("action") == "add")
update_count = sum(1 for x in preview_items if x.get("action") == "update")
if request.method == "GET":
return render_template("admin/import_preview.html", rows=list(enumerate(to_add)))
return render_template(
"admin/import_preview.html",
rows=list(enumerate(preview_items)),
add_count=add_count,
update_count=update_count,
)
selected = request.form.getlist("row_index")
if not selected:
to_add = session.get("import_preview") or []
return render_template("admin/import_preview.html", rows=list(enumerate(to_add)), error="请至少勾选一行")
to_add = session.get("import_preview") or []
indices = set(int(x) for x in selected if x.isdigit())
for i in indices:
if i < 0 or i >= len(to_add):
continue
row = to_add[i]
provider_name = (row.get("厂商") or "").strip()
if not provider_name:
continue
provider = Provider.query.filter_by(name=provider_name).first()
if not provider:
provider = Provider(name=provider_name, official_url=(row.get("厂商官网") or "").strip() or None)
db.session.add(provider)
db.session.flush()
plan = VPSPlan(
provider_id=provider.id,
provider=provider.name,
region=None,
name=None,
vcpu=_num(row.get("vCPU")),
memory_gb=_num(row.get("内存GB")),
storage_gb=_num(row.get("存储GB")),
bandwidth_mbps=_num(row.get("带宽Mbps")),
traffic=(row.get("流量") or "").strip() or None,
price_cny=_float(row.get("月付人民币")),
price_usd=_float(row.get("月付美元")),
currency=(row.get("货币") or "CNY").strip() or "CNY",
official_url=(row.get("配置官网") or "").strip() or None,
countries=(row.get("国家") or "").strip() or None,
return render_template(
"admin/import_preview.html",
rows=list(enumerate(preview_items)),
add_count=add_count,
update_count=update_count,
error="请至少勾选一行",
)
db.session.add(plan)
indices = sorted(set(int(x) for x in selected if x.isdigit()))
add_applied = 0
update_applied = 0
for i in indices:
if i < 0 or i >= len(preview_items):
continue
item = preview_items[i]
row = item.get("row") or {}
provider = _upsert_provider_from_row(row)
if not provider:
continue
action = item.get("action")
if action == "update":
plan = VPSPlan.query.get(item.get("plan_id"))
if not plan:
plan = VPSPlan()
db.session.add(plan)
add_applied += 1
else:
update_applied += 1
_fill_plan_from_row(plan, row, provider)
else:
plan = VPSPlan()
_fill_plan_from_row(plan, row, provider)
db.session.add(plan)
add_applied += 1
_record_price_history(plan, source="import")
db.session.commit()
session.pop("import_preview", None)
return redirect(url_for("admin_dashboard") + "?" + urlencode({"msg": "已导入 {} 条配置".format(len(indices))}))
msg = "已新增 {} 条,更新 {} 条配置".format(add_applied, update_applied)
return redirect(url_for("admin_dashboard") + "?" + urlencode({"msg": msg}))
if __name__ == "__main__":

View File

@@ -7,7 +7,7 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app import app, db
from models import VPSPlan
from models import VPSPlan, PriceHistory
# 默认官网链接(可按方案自定义)
DEFAULT_URLS = {
@@ -54,6 +54,19 @@ def main():
)
db.session.add(plan)
db.session.commit()
for plan in VPSPlan.query.all():
if plan.price_cny is None and plan.price_usd is None:
continue
if PriceHistory.query.filter_by(plan_id=plan.id).first():
continue
db.session.add(PriceHistory(
plan_id=plan.id,
price_cny=plan.price_cny,
price_usd=plan.price_usd,
currency=plan.currency or "CNY",
source="bootstrap",
))
db.session.commit()
print("已导入 %d 条初始方案。" % len(INITIAL_PLANS))
else:
print("数据库中已有数据,跳过导入。若要重新初始化请删除 vps_price.db 后重试。")

View File

@@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
"""数据库模型"""
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
from extensions import db
@@ -14,6 +16,40 @@ class Provider(db.Model):
plans = db.relationship("VPSPlan", backref="provider_rel", lazy="dynamic", foreign_keys="VPSPlan.provider_id")
class User(db.Model):
"""前台用户"""
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(32), nullable=False, unique=True, index=True)
password_hash = db.Column(db.String(255), nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, index=True)
last_login_at = db.Column(db.DateTime, nullable=True)
posts = db.relationship(
"ForumPost",
backref="author_rel",
lazy="dynamic",
cascade="all, delete-orphan",
foreign_keys="ForumPost.user_id",
)
comments = db.relationship(
"ForumComment",
backref="author_rel",
lazy="dynamic",
cascade="all, delete-orphan",
foreign_keys="ForumComment.user_id",
)
def set_password(self, raw_password):
self.password_hash = generate_password_hash(raw_password)
def check_password(self, raw_password):
if not self.password_hash:
return False
return check_password_hash(self.password_hash, raw_password)
class VPSPlan(db.Model):
"""云服务器配置/方案(属于某厂商)"""
__tablename__ = "vps_plans"
@@ -33,6 +69,13 @@ class VPSPlan(db.Model):
currency = db.Column(db.String(8), default="CNY")
official_url = db.Column(db.String(512), nullable=True) # 该配置详情页,可覆盖厂商默认
countries = db.Column(db.String(255), nullable=True, index=True)
price_histories = db.relationship(
"PriceHistory",
backref="plan_rel",
lazy="dynamic",
cascade="all, delete-orphan",
foreign_keys="PriceHistory.plan_id",
)
@property
def provider_name(self):
@@ -73,3 +116,48 @@ class VPSPlan(db.Model):
"official_url": self.official_url or (self.provider_rel.official_url if self.provider_rel else "") or "",
"countries": self.countries or "",
}
class PriceHistory(db.Model):
"""配置价格历史快照"""
__tablename__ = "price_histories"
id = db.Column(db.Integer, primary_key=True)
plan_id = db.Column(db.Integer, db.ForeignKey("vps_plans.id"), nullable=False, index=True)
price_cny = db.Column(db.Float, nullable=True)
price_usd = db.Column(db.Float, nullable=True)
currency = db.Column(db.String(8), nullable=False, default="CNY")
source = db.Column(db.String(32), nullable=True) # manual / import / bootstrap
captured_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, index=True)
class ForumPost(db.Model):
"""论坛帖子"""
__tablename__ = "forum_posts"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
title = db.Column(db.String(160), nullable=False, index=True)
content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, index=True)
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
comments = db.relationship(
"ForumComment",
backref="post_rel",
lazy="dynamic",
cascade="all, delete-orphan",
foreign_keys="ForumComment.post_id",
)
class ForumComment(db.Model):
"""论坛评论"""
__tablename__ = "forum_comments"
id = db.Column(db.Integer, primary_key=True)
post_id = db.Column(db.Integer, db.ForeignKey("forum_posts.id"), nullable=False, index=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, index=True)
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)

View File

@@ -204,6 +204,40 @@
color: var(--accent-dim);
}
.price-trend {
display: flex;
flex-direction: column;
gap: 0.15rem;
white-space: nowrap;
}
.price-trend .trend-delta {
font-family: var(--font-mono);
font-size: 0.82rem;
font-weight: 600;
}
.price-trend .trend-meta {
font-size: 0.75rem;
color: var(--text-muted);
}
.price-trend.trend-up .trend-delta {
color: var(--red);
}
.price-trend.trend-down .trend-delta {
color: var(--green);
}
.price-trend.trend-flat .trend-delta {
color: var(--text-muted);
}
.price-trend.trend-new .trend-delta {
color: var(--accent);
}
.admin-table .btn-delete {
background: none;
border: none;

237
static/css/forum.css Normal file
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
};
function toNumber(value) {
if (value == null || value === '') return null;
var n = Number(value);
return isNaN(n) ? null : n;
}
function getPriceValue(plan, currency) {
var cny = toNumber(plan.price_cny);
var usd = toNumber(plan.price_usd);
if (currency === 'USD') {
if (usd != null) return { value: usd, symbol: '$' };
if (cny != null) return { value: cny * exchangeRates.USD, symbol: '$' };
return null;
}
if (cny != null) return { value: cny, symbol: '¥' };
if (usd != null) return { value: usd / exchangeRates.USD, symbol: '¥' };
return null;
}
function getCnyPrice(plan) {
var cny = toNumber(plan.price_cny);
if (cny != null) return cny;
var usd = toNumber(plan.price_usd);
if (usd != null) return usd / exchangeRates.USD;
return null;
}
// ==================== 初始化 ====================
function init() {
fetchData();
@@ -213,7 +242,9 @@
var range = filters.price.split('-');
var min = parseFloat(range[0]);
var max = parseFloat(range[1]);
if (plan.price_cny < min || plan.price_cny > max) return false;
var cnyPrice = getCnyPrice(plan);
if (cnyPrice == null) return false;
if (cnyPrice < min || cnyPrice > max) return false;
}
// 搜索筛选
@@ -230,8 +261,17 @@
if (!currentSort.column) return plans;
return plans.slice().sort(function(a, b) {
var aVal = a[currentSort.column];
var bVal = b[currentSort.column];
var aVal;
var bVal;
if (currentSort.column === 'price') {
var aPrice = getPriceValue(a, filters.currency);
var bPrice = getPriceValue(b, filters.currency);
aVal = aPrice ? aPrice.value : Number.POSITIVE_INFINITY;
bVal = bPrice ? bPrice.value : Number.POSITIVE_INFINITY;
} else {
aVal = a[currentSort.column];
bVal = b[currentSort.column];
}
if (typeof aVal === 'number' && typeof bVal === 'number') {
return currentSort.direction === 'asc' ? aVal - bVal : bVal - aVal;
@@ -250,9 +290,8 @@
function createTableRow(plan) {
var tr = document.createElement('tr');
var price = convertPrice(plan.price_cny, filters.currency);
var priceSymbol = filters.currency === 'CNY' ? '¥' : '$';
var currentPrice = getPriceValue(plan, filters.currency);
var displayPrice = currentPrice ? currentPrice.symbol + currentPrice.value.toFixed(2) : '—';
tr.innerHTML =
'<td>' + escapeHtml(plan.provider) + '</td>' +
@@ -263,7 +302,7 @@
'<td>' + plan.storage_gb + ' GB</td>' +
'<td>' + (plan.bandwidth_mbps ? plan.bandwidth_mbps + ' Mbps' : '-') + '</td>' +
'<td>' + plan.traffic + '</td>' +
'<td class="col-price">' + priceSymbol + price + '</td>' +
'<td class="col-price">' + displayPrice + '</td>' +
'<td class="col-link">' +
'<a href="' + escapeHtml(plan.official_url) + '" target="_blank" rel="noopener" class="btn-link">访问</a>' +
'</td>';
@@ -271,12 +310,6 @@
return tr;
}
// ==================== 工具函数 ====================
function convertPrice(priceCNY, currency) {
var converted = priceCNY * exchangeRates[currency];
return converted.toFixed(2);
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;

View File

@@ -57,6 +57,7 @@
<th>内存</th>
<th>流量</th>
<th>月付</th>
<th>涨跌趋势</th>
<th>官网链接</th>
<th>操作</th>
</tr>
@@ -71,6 +72,17 @@
<td>{{ plan.memory_gb ~ ' GB' if plan.memory_gb is not none else '—' }}</td>
<td>{{ plan.traffic or '—' }}</td>
<td>{% if plan.price_cny is not none %}¥{{ plan.price_cny }}{% elif plan.price_usd is not none %}${{ plan.price_usd }}{% else %}—{% endif %}</td>
<td>
{% set trend = plan_trends.get(plan.id) %}
{% if trend %}
<div class="price-trend trend-{{ trend.direction }}">
<span class="trend-delta">{{ trend.delta_text }}</span>
<span class="trend-meta">{{ trend.meta_text }}</span>
</div>
{% else %}
{% endif %}
</td>
<td>{% if plan.official_url %}<a href="{{ plan.official_url }}" target="_blank" rel="noopener">链接</a>{% else %}—{% endif %}</td>
<td>
<button type="button" class="btn-inline btn-edit" data-plan-id="{{ plan.id }}">编辑</button>
@@ -81,7 +93,7 @@
</td>
</tr>
{% else %}
<tr><td colspan="9">暂无配置。</td></tr>
<tr><td colspan="10">暂无配置。</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -18,7 +18,7 @@
</nav>
</header>
<main class="admin-main">
<p class="hint">上传与导出格式一致的 .xlsx系统会与现有数据比对<strong>仅将「厂商+配置+价格」在库中不存在的行</strong>列为待确认;确认后才会写入数据库并展示</p>
<p class="hint">上传与导出格式一致的 .xlsx系统会与现有数据比对自动识别<strong>新增项</strong><strong>可更新项</strong>;在预览页勾选确认后写入数据库。</p>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
@@ -28,7 +28,7 @@
<input type="file" id="file" name="file" accept=".xlsx" required>
</div>
<div class="form-actions">
<button type="submit">上传并预览待新增</button>
<button type="submit">上传并预览待处理项</button>
<a href="{{ url_for('admin_dashboard') }}" class="btn-cancel">取消</a>
</div>
</form>

View File

@@ -17,18 +17,19 @@
</nav>
</header>
<main class="admin-main">
<p class="hint">以下为与现有数据不重复的配置,勾选需要写入的项后点击「确认导入」即可正式入库并展示</p>
<p class="hint">本次解析结果:新增 {{ add_count or 0 }} 条,可更新 {{ update_count or 0 }} 条。勾选后点击「确认导入」执行写入</p>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
{% if not rows %}
<p>没有待新增数据(可能全部已存在)。<a href="{{ url_for('admin_import') }}">重新上传</a></p>
<p>没有待处理数据(可能全部已存在且无变化)。<a href="{{ url_for('admin_import') }}">重新上传</a></p>
{% else %}
<form method="post" action="{{ url_for('admin_import_preview') }}">
<table class="admin-table">
<thead>
<tr>
<th><input type="checkbox" id="check-all" checked></th>
<th>动作</th>
<th>厂商</th>
<th>国家</th>
<th>vCPU</th>
@@ -37,13 +38,16 @@
<th>流量</th>
<th>月付¥</th>
<th>月付$</th>
<th>变更</th>
<th>配置官网</th>
</tr>
</thead>
<tbody>
{% for idx, row in rows %}
{% for idx, item in rows %}
{% set row = item.get('row', {}) %}
<tr>
<td><input type="checkbox" name="row_index" value="{{ idx }}" checked></td>
<td>{% if item.get('action') == 'update' %}更新 #{{ item.get('plan_id') }}{% else %}新增{% endif %}</td>
<td>{{ row.get('厂商') or '—' }}</td>
<td>{{ row.get('国家') or '—' }}</td>
<td>{{ row.get('vCPU') or '—' }}</td>
@@ -52,6 +56,18 @@
<td>{{ row.get('流量') or '—' }}</td>
<td>{{ row.get('月付人民币') or '—' }}</td>
<td>{{ row.get('月付美元') or '—' }}</td>
<td>
{% if item.get('action') == 'add' %}
新配置
{% else %}
{% for c in item.get('changes', []) %}
<div>{{ c.get('label') }}{{ c.get('old_display') }} → {{ c.get('new_display') }}</div>
{% endfor %}
{% if item.get('provider_url_changed') %}
<div>厂商官网:{{ item.get('provider_url_old') or '—' }} → {{ item.get('provider_url_new') or '—' }}</div>
{% endif %}
{% endif %}
</td>
<td>{{ (row.get('配置官网') or '')[:30] }}{% if (row.get('配置官网') or '')|length > 30 %}…{% endif %}</td>
</tr>
{% endfor %}

View File

@@ -37,6 +37,7 @@
<th>内存</th>
<th>流量</th>
<th>月付</th>
<th>涨跌趋势</th>
<th>官网链接</th>
<th>操作</th>
</tr>
@@ -50,6 +51,17 @@
<td>{{ plan.memory_gb ~ ' GB' if plan.memory_gb is not none else '—' }}</td>
<td>{{ plan.traffic or '—' }}</td>
<td>{% if plan.price_cny is not none %}¥{{ plan.price_cny }}{% elif plan.price_usd is not none %}${{ plan.price_usd }}{% else %}—{% endif %}</td>
<td>
{% set trend = plan_trends.get(plan.id) %}
{% if trend %}
<div class="price-trend trend-{{ trend.direction }}">
<span class="trend-delta">{{ trend.delta_text }}</span>
<span class="trend-meta">{{ trend.meta_text }}</span>
</div>
{% else %}
{% endif %}
</td>
<td>{% if plan.official_url %}<a href="{{ plan.official_url }}" target="_blank" rel="noopener">链接</a>{% else %}—{% endif %}</td>
<td>
<button type="button" class="btn-inline btn-edit" data-plan-id="{{ plan.id }}">编辑</button>
@@ -60,7 +72,7 @@
</td>
</tr>
{% else %}
<tr><td colspan="8">该厂商下暂无配置,在上方表单添加。</td></tr>
<tr><td colspan="9">该厂商下暂无配置,在上方表单添加。</td></tr>
{% endfor %}
</tbody>
</table>

42
templates/auth/login.html Normal file
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>