feat: update assessment portal
This commit is contained in:
+6
-3
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user