feat: update assessment portal

This commit is contained in:
2026-07-06 16:53:06 +08:00
parent a75e857c71
commit f385f9747b
17 changed files with 1866 additions and 884 deletions
+6 -3
View File
@@ -7,7 +7,7 @@ from flask import Flask, abort, jsonify, request
from .config import Config, DATA_DIR, ensure_dirs
from .extensions import db, login_manager
from .models import User
from .models import AppSetting, User
from .prediction import FEATURES, load_model
from .security import csrf_token, validate_csrf_token
@@ -95,7 +95,10 @@ def register_app_hooks(app: Flask) -> None:
"feature_list": FEATURES,
"now_year": now_year,
"csrf_token": csrf_token,
"allow_registration": app.config["ALLOW_REGISTRATION"],
"allow_registration": AppSetting.get_bool(
"allow_registration",
app.config["ALLOW_REGISTRATION"],
),
}
@app.errorhandler(413)
@@ -111,6 +114,6 @@ def register_app_hooks(app: Flask) -> None:
return None
if validate_csrf_token():
return None
if request.path == "/predict":
if request.path == "/predict" or request.headers.get("X-Requested-With") == "XMLHttpRequest":
return jsonify({"error": "CSRF 校验失败,请刷新页面后重试。"}), 400
abort(400)
+25
View File
@@ -36,3 +36,28 @@ class UploadRecord(db.Model):
upload_time = db.Column(db.DateTime, default=datetime.utcnow)
user = db.relationship("User", backref=db.backref("uploads", lazy=True))
class AppSetting(db.Model):
__tablename__ = "app_settings"
id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String(100), unique=True, nullable=False)
value = db.Column(db.String(255), nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@classmethod
def get_bool(cls, key: str, default: bool = False) -> bool:
setting = cls.query.filter_by(key=key).first()
if setting is None:
return default
return setting.value.strip().lower() in {"1", "true", "yes", "on"}
@classmethod
def set_bool(cls, key: str, value: bool) -> "AppSetting":
setting = cls.query.filter_by(key=key).first()
if setting is None:
setting = cls(key=key, value="")
db.session.add(setting)
setting.value = "true" if value else "false"
return setting
+74 -17
View File
@@ -23,6 +23,7 @@ from .config import IMAGE_DIR, UPLOAD_DIR
CHINESE_FONT_CANDIDATES = [
"Noto Sans CJK SC",
"Noto Sans SC",
"Noto Sans CJK JP",
"Noto Sans CJK TC",
"Source Han Sans SC",
@@ -32,6 +33,17 @@ CHINESE_FONT_CANDIDATES = [
"Arial Unicode MS",
]
CHINESE_FONT_FILES = [
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/noto/NotoSansSC-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/local/share/fonts/NotoSansCJK-Regular.ttc",
"/mnt/c/Windows/Fonts/NotoSansSC-VF.ttf",
"/mnt/c/Windows/Fonts/msyh.ttc",
"/mnt/c/Windows/Fonts/simhei.ttf",
"/mnt/c/Windows/Fonts/simsun.ttc",
]
FEATURES = [
"管材",
"管径",
@@ -47,6 +59,7 @@ FEATURES = [
ID_COLUMN = "管道编号"
PIPE_AGE_COLUMN = "管龄"
SUPPORTED_EXTENSIONS = {".csv", ".xls", ".xlsx"}
CHINESE_FONT_PROP = None
class PredictionError(Exception):
@@ -69,14 +82,34 @@ class PredictionArtifacts:
analysis_text: str
def configure_matplotlib_fonts() -> None:
def configure_matplotlib_fonts():
for font_path in CHINESE_FONT_FILES:
path = Path(font_path)
if path.exists():
font_manager.fontManager.addfont(str(path))
prop = font_manager.FontProperties(fname=str(path))
rcParams["font.family"] = [prop.get_name(), "sans-serif"]
rcParams["font.sans-serif"] = [prop.get_name(), *CHINESE_FONT_CANDIDATES, "DejaVu Sans"]
rcParams["axes.unicode_minus"] = False
return prop
available_fonts = {font.name for font in font_manager.fontManager.ttflist}
selected_fonts = [font for font in CHINESE_FONT_CANDIDATES if font in available_fonts]
if selected_fonts:
rcParams["font.family"] = [selected_fonts[0], "sans-serif"]
rcParams["font.sans-serif"] = selected_fonts + ["DejaVu Sans"]
rcParams["axes.unicode_minus"] = False
if selected_fonts:
return font_manager.FontProperties(family=selected_fonts[0])
logging.warning("未找到中文字体,生成的图表中文可能无法显示。")
return None
configure_matplotlib_fonts()
CHINESE_FONT_PROP = configure_matplotlib_fonts()
def chinese_font_kwargs() -> dict[str, Any]:
return {"fontproperties": CHINESE_FONT_PROP} if CHINESE_FONT_PROP else {}
def load_model(model_path: str):
@@ -105,7 +138,7 @@ def safe_unlink(path: Path) -> None:
def secure_upload_name(original_filename: str, run_id: str) -> tuple[str, str]:
suffix = Path(original_filename).suffix.lower()
if suffix not in SUPPORTED_EXTENSIONS:
raise PredictionError("不支持的格式,仅支持 CSV / XLS / XLSX")
raise PredictionError("不支持的格式,仅支持逗号分隔值文件或电子表格文件")
safe_full_name = secure_filename(original_filename)
safe_stem = Path(safe_full_name).stem if safe_full_name else ""
@@ -228,10 +261,14 @@ def render_importance_chart(values: np.ndarray, save_path: Path) -> None:
fig, ax = plt.subplots(figsize=(9, max(3.0, 0.62 * n + 1.6)))
cmap = LinearSegmentedColormap.from_list("brand", ["#7fb2e6", "#005EB8", "#0c4188"])
colors = cmap(np.linspace(0.15, 1.0, n)) if n else None
bars = ax.barh(sorted_feats, sorted_vals, color=colors, height=0.66, edgecolor="white", linewidth=0.8, zorder=3)
y_pos = np.arange(n)
bars = ax.barh(y_pos, sorted_vals, color=colors, height=0.66, edgecolor="white", linewidth=0.8, zorder=3)
ax.set_xlabel("相对重要性", fontsize=11, color="#475569")
ax.set_title("模型输入因素重要性排序", fontsize=15, fontweight="bold", color="#0f172a", pad=14)
font_kwargs = chinese_font_kwargs()
ax.set_xlabel("相对重要性", fontsize=11, color="#475569", **font_kwargs)
ax.set_title("模型输入因素重要性排序", fontsize=15, fontweight="bold", color="#0f172a", pad=14, **font_kwargs)
ax.set_yticks(y_pos)
ax.set_yticklabels(sorted_feats, **font_kwargs)
ax.grid(axis="x", color="#e2e8f0", linewidth=1, zorder=0)
ax.set_axisbelow(True)
for spine in ("top", "right", "left"):
@@ -239,6 +276,9 @@ def render_importance_chart(values: np.ndarray, save_path: Path) -> None:
ax.spines["bottom"].set_color("#cbd5e1")
ax.tick_params(axis="y", length=0, labelsize=11)
ax.tick_params(axis="x", colors="#94a3b8", labelsize=9)
if CHINESE_FONT_PROP:
for label in [*ax.get_yticklabels(), *ax.get_xticklabels()]:
label.set_fontproperties(CHINESE_FONT_PROP)
max_val = float(sorted_vals.max()) if n else 0.0
for bar, value in zip(bars, sorted_vals):
@@ -251,6 +291,7 @@ def render_importance_chart(values: np.ndarray, save_path: Path) -> None:
fontsize=10,
fontweight="bold",
color="#1e293b",
**font_kwargs,
)
if max_val > 0:
ax.set_xlim(0, max_val * 1.18)
@@ -352,12 +393,17 @@ def render_survival_chart(df: pd.DataFrame, curves, image_path: Path) -> tuple[l
)
plt.step(times, probs, where="post", linewidth=2, label=pipe_id)
plt.xlabel("预测时间轴(年)")
plt.ylabel("生存概率")
plt.title("预测分析图")
font_kwargs = chinese_font_kwargs()
plt.xlabel("预测时间轴(年)", **font_kwargs)
plt.ylabel("生存概率", **font_kwargs)
plt.title("预测分析图", **font_kwargs)
plt.grid(alpha=0.18)
if len(summary_rows) <= 12:
plt.legend(loc="best", fontsize=8)
plt.legend(loc="best", fontsize=8, prop=CHINESE_FONT_PROP)
ax = plt.gca()
if CHINESE_FONT_PROP:
for label in [*ax.get_xticklabels(), *ax.get_yticklabels()]:
label.set_fontproperties(CHINESE_FONT_PROP)
plt.tight_layout()
plt.savefig(image_path, dpi=160, bbox_inches="tight")
plt.close()
@@ -370,11 +416,22 @@ def write_prediction_workbook(
summary_rows: list[dict[str, Any]],
summary_sheet_rows: list[dict[str, Any]],
) -> None:
with pd.ExcelWriter(excel_path, engine="xlsxwriter") as writer:
sample_data_rows: list[dict[str, Any]] = []
for i, curve in enumerate(curves):
times = [float(x) for x in list(curve.x)]
probs = [float(y) for y in list(curve.y)]
pipe_id = summary_rows[i]["pipe_id"]
for time, probability in zip(times, probs):
sample_data_rows.append(
{
"管道编号": pipe_id,
"样本序号": i + 1,
"时间(年)": time,
"生存概率": probability,
"风险概率": 1 - probability,
}
)
with pd.ExcelWriter(excel_path, engine="openpyxl") as writer:
pd.DataFrame(summary_sheet_rows).to_excel(writer, sheet_name="结果摘要", index=False)
for i, curve in enumerate(curves):
times = [float(x) for x in list(curve.x)]
probs = [float(y) for y in list(curve.y)]
pipe_id = summary_rows[i]["pipe_id"]
out_df = pd.DataFrame({"时间(年)": times, f"{pipe_id}生存概率": probs})
out_df.to_excel(writer, sheet_name=f"样本{i+1}", index=False)
pd.DataFrame(sample_data_rows).to_excel(writer, sheet_name="样本数据", index=False)
+92 -10
View File
@@ -17,14 +17,32 @@ from flask import (
url_for,
)
from flask_login import current_user, login_required, login_user, logout_user
from sqlalchemy.orm import joinedload
from .config import BASE_DIR
from .extensions import db
from .models import UploadRecord, User
from .models import AppSetting, UploadRecord, User
from .prediction import PredictionError, run_prediction
from .security import new_captcha
bp = Blueprint("main", __name__)
REFERENCE_PDF_NAME = "20260630标准文本——供水管道健康状态与剩余寿命评估技术导则.pdf"
REGISTRATION_SETTING_KEY = "allow_registration"
RECORDS_PER_PAGE = 10
def registration_allowed() -> bool:
return AppSetting.get_bool(
REGISTRATION_SETTING_KEY,
current_app.config["ALLOW_REGISTRATION"],
)
def requested_page() -> int:
try:
return max(int(request.args.get("page", 1)), 1)
except (TypeError, ValueError):
return 1
@bp.route("/")
@@ -64,12 +82,13 @@ def login():
@bp.route("/register", methods=["GET", "POST"])
def register():
if not current_app.config["ALLOW_REGISTRATION"]:
abort(404)
if request.method == "GET":
return render_template("login.html", mode="register", captcha="")
if not registration_allowed():
flash("当前未开放自助注册,请联系管理员。", "error")
return render_template("login.html", mode="register", captcha=""), 403
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
@@ -108,12 +127,19 @@ def home():
@bp.route("/history")
@login_required
def history_page():
records = (
page = requested_page()
pagination = (
UploadRecord.query.filter_by(user_id=current_user.id)
.order_by(UploadRecord.upload_time.desc())
.all()
.paginate(page=page, per_page=RECORDS_PER_PAGE, error_out=False)
)
if pagination.pages and page > pagination.pages:
return redirect(url_for("main.history_page", page=pagination.pages))
return render_template(
"history.html",
pagination=pagination,
records=pagination.items,
)
return render_template("history.html", records=records)
@bp.route("/admin")
@@ -121,8 +147,44 @@ def history_page():
def admin_dashboard():
if not current_user.is_admin:
abort(403)
records = UploadRecord.query.order_by(UploadRecord.upload_time.desc()).all()
return render_template("admin.html", records=records)
page = requested_page()
pagination = (
UploadRecord.query.options(joinedload(UploadRecord.user))
.order_by(UploadRecord.upload_time.desc())
.paginate(page=page, per_page=RECORDS_PER_PAGE, error_out=False)
)
if pagination.pages and page > pagination.pages:
return redirect(url_for("main.admin_dashboard", page=pagination.pages))
return render_template(
"admin.html",
pagination=pagination,
records=pagination.items,
registration_allowed=registration_allowed(),
)
@bp.route("/admin/registration", methods=["POST"])
@login_required
def update_registration_setting():
if not current_user.is_admin:
abort(403)
allow_registration = request.form.get("allow_registration") == "on"
AppSetting.set_bool(REGISTRATION_SETTING_KEY, allow_registration)
db.session.commit()
message = "已开放用户自助注册" if allow_registration else "已关闭用户自助注册"
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return jsonify(
{
"message": message,
"registration_allowed": allow_registration,
"status_label": "已开放" if allow_registration else "已关闭",
}
)
flash(message, "info")
return redirect(url_for("main.admin_dashboard"))
@bp.route("/download/<int:record_id>/<file_type>")
@@ -152,6 +214,26 @@ def download_template():
return send_file(template_path, as_attachment=True, download_name="example.xlsx")
@bp.route("/reference_pdf")
@login_required
def reference_pdf():
pdf_path = BASE_DIR / REFERENCE_PDF_NAME
if not pdf_path.exists():
abort(404)
return send_file(
pdf_path,
as_attachment=False,
download_name=REFERENCE_PDF_NAME,
mimetype="application/pdf",
)
@bp.route("/reference")
@login_required
def reference_page():
return render_template("reference.html")
@bp.route("/result")
@login_required
def result_page():
@@ -197,7 +279,7 @@ def predict():
"excel_url": url_for("main.download_file", record_id=record.id, file_type="prediction"),
"result_url": url_for("main.result_page"),
"sample_count": int(artifacts.sample_count),
"summary_rows": artifacts.summary_rows[:3],
"summary_rows": artifacts.summary_rows[:6],
"analysis_text": artifacts.analysis_text,
}
session["last_result"] = last_result