diff --git a/20260630标准文本——供水管道健康状态与剩余寿命评估技术导则.pdf b/20260630标准文本——供水管道健康状态与剩余寿命评估技术导则.pdf new file mode 100644 index 0000000..44672a0 Binary files /dev/null and b/20260630标准文本——供水管道健康状态与剩余寿命评估技术导则.pdf differ diff --git a/app/__init__.py b/app/__init__.py index cb0289c..3d8cc8b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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) diff --git a/app/models.py b/app/models.py index 5e5eb98..8e39957 100644 --- a/app/models.py +++ b/app/models.py @@ -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 diff --git a/app/prediction.py b/app/prediction.py index af7e0a8..86483c6 100644 --- a/app/prediction.py +++ b/app/prediction.py @@ -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) diff --git a/app/routes.py b/app/routes.py index 9c643e0..2a07ced 100644 --- a/app/routes.py +++ b/app/routes.py @@ -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//") @@ -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 diff --git a/main.py b/main.py index 47f8895..c502874 100644 --- a/main.py +++ b/main.py @@ -4,4 +4,4 @@ app = create_app() if __name__ == "__main__": - app.run(host="0.0.0.0", port=5005, debug=False, use_reloader=False) + app.run(host="0.0.0.0", port=5005, debug=True, use_reloader=False) diff --git a/static/js/dashboard.js b/static/js/dashboard.js index fb8e010..8feffc5 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -6,46 +6,159 @@ const submitBtn = document.getElementById('submitBtn'); const submitText = document.getElementById('submitText'); let submitIcon = document.getElementById('submitIcon'); const alertBox = document.getElementById('alertBox'); -const mainGrid = document.getElementById('mainGrid'); +const alertIconWrap = document.getElementById('alertIconWrap'); +const alertIcon = document.getElementById('alertIcon'); +const alertTitle = document.getElementById('alertTitle'); +const alertMessage = document.getElementById('alertMessage'); +const alertClose = document.getElementById('alertClose'); const inlineResult = document.getElementById('inlineResult'); +const resultPlaceholder = document.getElementById('resultPlaceholder'); +const resultContent = document.getElementById('resultContent'); const resultImage = document.getElementById('resultImage'); const resultImportanceWrap = document.getElementById('resultImportanceWrap'); const resultImportanceImage = document.getElementById('resultImportanceImage'); const excelBtn = document.getElementById('excelBtn'); const resultPageBtn = document.getElementById('resultPageBtn'); -const summaryFilename = document.getElementById('summaryFilename'); -const summaryCount = document.getElementById('summaryCount'); +const homeStateKey = 'pipelineLifetime.homeState'; +let selectedFile = null; +let alertTimer = null; +let alertHideTimer = null; -function showAlert(message, type='error') { - alertBox.classList.remove('hidden', 'bg-dangerSoft', 'text-dangerText', 'border-red-200', 'bg-blueSoft', 'text-primary', 'border-blue-200'); +function hideAlert() { + clearTimeout(alertTimer); + clearTimeout(alertHideTimer); + alertBox.classList.remove('opacity-100', 'translate-y-0'); + alertBox.classList.add('pointer-events-none', 'opacity-0', 'translate-y-3'); + alertHideTimer = setTimeout(() => { + alertBox.classList.add('hidden'); + }, 200); +} + +function showAlert(message, type='error', title) { + clearTimeout(alertTimer); + clearTimeout(alertHideTimer); + alertBox.classList.remove('hidden', 'border-red-200', 'border-blue-200'); + alertIconWrap.classList.remove('bg-dangerSoft', 'text-dangerText', 'bg-blueSoft', 'text-primary'); if (type === 'error') { - alertBox.classList.add('bg-dangerSoft', 'text-dangerText', 'border-red-200'); + alertBox.classList.add('border-red-200'); + alertIconWrap.classList.add('bg-dangerSoft', 'text-dangerText'); + alertIcon.textContent = 'priority_high'; + alertTitle.textContent = title || '需要补充文件'; } else { - alertBox.classList.add('bg-blueSoft', 'text-primary', 'border-blue-200'); + alertBox.classList.add('border-blue-200'); + alertIconWrap.classList.add('bg-blueSoft', 'text-primary'); + alertIcon.textContent = 'check_circle'; + alertTitle.textContent = title || '处理完成'; + } + alertMessage.textContent = message; + requestAnimationFrame(() => { + alertBox.classList.remove('pointer-events-none', 'opacity-0', 'translate-y-3'); + alertBox.classList.add('opacity-100', 'translate-y-0'); + }); + alertTimer = setTimeout(() => { + hideAlert(); + }, 10000); +} + +alertClose.addEventListener('click', hideAlert); + +function updateSelectedFile(file) { + selectedFile = file; + selectedFileName.textContent = '已选择文件:' + file.name; + selectedFileName.classList.remove('hidden'); + saveHomeState({ selectedFilename: file.name }); +} + +function readHomeState() { + try { + return JSON.parse(sessionStorage.getItem(homeStateKey)) || {}; + } catch (err) { + return {}; + } +} + +function saveHomeState(nextState) { + try { + const currentState = readHomeState(); + sessionStorage.setItem(homeStateKey, JSON.stringify({ ...currentState, ...nextState })); + } catch (err) { + // Ignore storage failures; prediction still works without client-side restore. + } +} + +function renderResult(data) { + resultImage.src = data.image_url; + if (data.importance_url) { + resultImportanceImage.src = data.importance_url; + resultImportanceWrap.classList.remove('hidden'); + } else { + resultImportanceWrap.classList.add('hidden'); + resultImportanceImage.removeAttribute('src'); + } + excelBtn.href = data.excel_url; + resultPageBtn.href = data.result_url; + resultPlaceholder.classList.add('hidden'); + resultContent.classList.remove('hidden'); + resultContent.classList.add('flex'); +} + +function restoreHomeState() { + const savedState = readHomeState(); + if (savedState.selectedFilename) { + selectedFileName.textContent = '上次选择文件:' + savedState.selectedFilename + '(需重新选择)'; + selectedFileName.classList.remove('hidden'); + } + if (savedState.result) { + renderResult(savedState.result); } - alertBox.textContent = message; } fileInput.addEventListener('change', () => { const file = fileInput.files[0]; if (!file) return; - selectedFileName.textContent = '已选择文件:' + file.name; - selectedFileName.classList.remove('hidden'); + updateSelectedFile(file); }); + ['dragenter', 'dragover'].forEach(evt => dropZone.addEventListener(evt, e => { e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'copy'; dropZone.classList.add('border-primary', 'bg-blue-50'); })); -['dragleave', 'drop'].forEach(evt => dropZone.addEventListener(evt, e => { + +dropZone.addEventListener('dragleave', e => { e.preventDefault(); + e.stopPropagation(); dropZone.classList.remove('border-primary', 'bg-blue-50'); -})); +}); + +dropZone.addEventListener('drop', e => { + e.preventDefault(); + e.stopPropagation(); + dropZone.classList.remove('border-primary', 'bg-blue-50'); + + const file = e.dataTransfer.files[0]; + if (!file) return; + + if (window.DataTransfer) { + const transfer = new DataTransfer(); + transfer.items.add(file); + fileInput.files = transfer.files; + } + + updateSelectedFile(file); +}); form.addEventListener('submit', async (e) => { e.preventDefault(); - alertBox.classList.add('hidden'); - if (!fileInput.files.length) { - showAlert('请先选择要上传的文件。'); + hideAlert(); + if (!selectedFile) { + showAlert('点击上传区域选择电子表格或逗号分隔值文件,也可以直接把文件拖放到上传框内。', 'error', '请先选择文件'); + dropZone.classList.add('border-red-300', 'bg-red-50', 'ring-4', 'ring-red-100'); + dropZone.scrollIntoView({ behavior: 'smooth', block: 'center' }); + setTimeout(() => { + dropZone.classList.remove('border-red-300', 'bg-red-50', 'ring-4', 'ring-red-100'); + }, 2400); return; } @@ -55,27 +168,24 @@ form.addEventListener('submit', async (e) => { try { const formData = new FormData(form); + formData.set('file', selectedFile); const resp = await fetch(form.action, { method: 'POST', body: formData }); const data = await resp.json(); if (!resp.ok) { showAlert(data.error || '预测失败,请稍后重试。'); return; } - resultImage.src = data.image_url; - if (data.importance_url) { - resultImportanceImage.src = data.importance_url; - resultImportanceWrap.classList.remove('hidden'); - } else { - resultImportanceWrap.classList.add('hidden'); - } - excelBtn.href = data.excel_url; - resultPageBtn.href = data.result_url; - summaryFilename.textContent = data.original_filename; - summaryCount.textContent = data.sample_count; - inlineResult.classList.remove('hidden'); - mainGrid.classList.remove('xl:grid-cols-[1.45fr_1fr]'); - mainGrid.classList.add('xl:grid-cols-[1.25fr_0.95fr_1.05fr]'); - showAlert('预测完成,已生成图表与 Excel 报告。', 'success'); + renderResult(data); + saveHomeState({ + selectedFilename: data.original_filename || selectedFile.name, + result: { + image_url: data.image_url, + importance_url: data.importance_url || '', + excel_url: data.excel_url, + result_url: data.result_url + } + }); + showAlert('预测完成,已生成图表与电子表格报告。', 'success'); inlineResult.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } catch (err) { showAlert('请求失败,请检查后端服务是否正常。'); @@ -87,3 +197,5 @@ form.addEventListener('submit', async (e) => { submitIcon = restoredIcon; } }); + +restoreHomeState(); diff --git a/templates/_pagination.html b/templates/_pagination.html new file mode 100644 index 0000000..c2d8352 --- /dev/null +++ b/templates/_pagination.html @@ -0,0 +1,45 @@ +{% macro render_pagination(pagination, endpoint) %} +{% if pagination.total %} + +{% endif %} +{% endmacro %} diff --git a/templates/admin.html b/templates/admin.html index df6b672..7e0f587 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -1,110 +1,129 @@ +{% extends "base.html" %} +{% from "_pagination.html" import render_pagination %} - - - - 管理台 +{% set active_page = "admin" %} +{% block title %}管理台 | 供水管道健康评估系统{% endblock %} - - - - - - - +{% block content %} +
+
+

管理台

+

管理系统注册状态,查看所有用户的上传文件和预测结果。

+
+ + arrow_back + 返回主页 + +
- - -
-
-

管理员查看上传记录

- 返回主页 +
+
+
+

用户注册

+

+ 当前状态:{{ '已开放' if registration_allowed else '已关闭' }} +

-
- - - - - - - - - - - {% for record in records %} - - - - - - - {% else %} - - {% endfor %} - -
用户原始文件上传时间下载
{{ record.user.username }}{{ record.original_filename }}{{ record.upload_time.strftime('%Y-%m-%d %H:%M:%S') }} - 原始文件 - 预测结果 -
暂无上传记录
+
+ + + +
+
+
+ +
+
+
+

上传记录

+ {% if pagination.total %} + 共 {{ pagination.total }} 条 + {% endif %}
- - +
+ + + + + + + + + + + {% for record in records %} + + + + + + + {% else %} + + + + {% endfor %} + +
用户原始文件上传时间下载
{{ record.user.username }}{{ record.original_filename }}{{ record.upload_time.strftime('%Y-%m-%d %H:%M:%S') }} + +
暂无上传记录
+
+ {{ render_pagination(pagination, 'main.admin_dashboard') }} +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..4fb1de1 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,294 @@ + + + + {% block title %}供水管道健康评估系统{% endblock %} + + + + + + + + {% block head_extra %}{% endblock %} + + +
+
+
+ + water_drop + 供水管道健康评估系统 + + +
+ +
+ + +
+
+
+
+ +
+ + {% with flashed_messages = get_flashed_messages(with_categories=true) %} + + + {% endwith %} + +
+ {% block content %}{% endblock %} +
+ +
+
+ 预测结果仅供参考 + © {{ now_year() }} 供水管道健康评估系统 +
+
+ + + {% block scripts %}{% endblock %} + + diff --git a/templates/history.html b/templates/history.html index e543c40..85be932 100644 --- a/templates/history.html +++ b/templates/history.html @@ -1,99 +1,60 @@ +{% extends "base.html" %} +{% from "_pagination.html" import render_pagination %} - - - - 预测历史 +{% set active_page = "history" %} +{% block title %}预测历史 | 供水管道健康评估系统{% endblock %} - - - - - - - +{% block content %} +
+
+

预测历史

+

查看已上传文件和对应预测报告。

+
+ + upload_file + 新建分析 + +
- - -
-
-

预测历史

- 返回主页 -
-
- {% for record in records %} -
-
-
{{ record.original_filename }}
-
{{ record.upload_time.strftime('%Y-%m-%d %H:%M:%S') }}
-
- -
- {% else %} -
还没有历史记录。
- {% endfor %} +
+
+
+

上传记录

+ {% if pagination.total %} + 共 {{ pagination.total }} 条 + {% endif %}
- - +
+ {% for record in records %} +
+
+
{{ record.original_filename }}
+
+ {{ record.upload_time.strftime('%Y-%m-%d %H:%M:%S') }} + + 记录 #{{ record.id }} +
+
+ +
+ {% else %} +
+ history +

还没有历史记录

+

上传数据并完成预测后,记录会显示在这里。

+
+ {% endfor %} +
+ {{ render_pagination(pagination, 'main.history_page') }} +
+{% endblock %} diff --git a/templates/home.html b/templates/home.html index 8d64819..77b3ec8 100644 --- a/templates/home.html +++ b/templates/home.html @@ -1,252 +1,150 @@ +{% extends "base.html" %} - - - - +{% set active_page = "home" %} +{% block title %}主页 | 供水管道健康评估系统{% endblock %} - - - - - - - +{% block content %} +
+
+

管道健康状态与剩余寿命评估

+

上传标准数据文件,系统将生成生存概率曲线、健康等级摘要、剩余寿命判断和电子表格预测报告。

+
+ + download + 下载数据模板 + +
- - -
-
-
-
-
- water_drop - 供水管道健康评估系统 -
- +
+
+
+
+

上传分析文件

+

支持电子表格(.xlsx/.xls)和逗号分隔值文件(.csv),字段名需与模板一致。

-
- notifications - help -
-
-
{{ current_user.username[:1]|upper }}
-
- - -
+ + verified + 标准化输入 + +
+ +
+ + + +
+

预测结果会在本页生成摘要,并可进入结果页查看完整报告。

+ +
+
+
+ +
+
+
+

最新预测结果

+

上传并分析文件后,这里会显示预测图表与报告入口。

+
+
+ monitoring +
暂无预测结果
+
完成一次分析后,将生成生存概率阶梯图、摘要和电子表格报告。
+
+
+ + -
-
+ -
-
-

供水管道健康状态与剩余寿命评估技术导则

-

上传您的数据以生成预测结果供参考。

-
- - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
{{ message }}
- {% endfor %} -
- {% endif %} - {% endwith %} - - -
-
-
-
-

- upload_file - 文件上传 -

- - download - 下载模板 - -
- -
- - - -
- -
-
-
- - -
- - - - +
+

+ rule + 数据要求 +

+

为确保准确的生存分析,请确保数据类型严格遵循模板。

+
+
+
必填基础信息
+
管道编号、管龄、状态、管材、管径
-
+
+
选填历史信息
+
流速、压力、温度、降雨量、位置
+
+
+
选填内壁特征
+
结构缺陷、功能缺陷
+
+
+
字段格式
+
字段名需与模板完全一致
+
+
+ -
- 预测结果仅供参考 -
- 文档 -
-
+
+

+ quick_reference + 快速入口 +

+ +
+
+{% endblock %} - - - - +{% block scripts %} + +{% endblock %} diff --git a/templates/login.html b/templates/login.html index 0bca6d1..0d421a5 100644 --- a/templates/login.html +++ b/templates/login.html @@ -1,85 +1,232 @@ - - {{ '注册' if mode == 'register' else '登录' }} | - - - - - - - + + + - - + + + {% set page_notice = "当前未开放自助注册。系统仅支持管理员分配账号,请联系管理员完成账号开通后再登录。" if mode == 'register' and not allow_registration else none %} + {% with flashed_messages = get_flashed_messages(with_categories=true) %} + + + {% endwith %} +
-
-
-
-

系统门户

- -
- 登录 - {% if allow_registration %} - 注册 - {% endif %} +
+
+
+ +
+ water_drop + 供水管道健康评估系统 +
+

系统门户

-
-
- 系统状态 - 服务条款 - API 文档 +
+ 登录 + 注册 +
+ + {% if mode == 'login' %} +
+ +
+ +
+ person + +
-
-
+ +
+
+ + 找回密码 +
+
+ lock + + +
+
+ +
+ +
+
+ verified_user + +
+
{{ captcha }}
+ + refresh + +
+
+ + + + + + {% else %} +
+ +
+ +
+ badge + +
+
+
+ +
+ lock + + +
+
+ +
+ {% endif %} +
+
+ +
+ diff --git a/templates/reference.html b/templates/reference.html new file mode 100644 index 0000000..bb86565 --- /dev/null +++ b/templates/reference.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% set active_page = "reference" %} +{% block title %}技术导则 | 供水管道健康评估系统{% endblock %} + +{% block content %} +
+
+

技术导则参考文档

+

供水管道健康状态与剩余寿命评估技术导则

+
+ + open_in_new + 新窗口查看 + +
+ +
+
+
+ picture_as_pdf + 文档预览 +
+ 浏览器不支持内嵌时可使用右上角按钮 +
+
+ +
+ picture_as_pdf +

当前浏览器无法内嵌显示参考文档。

+ 打开参考文档 +
+
+
+
+{% endblock %} diff --git a/templates/result.html b/templates/result.html index cf8dc2e..c9a1f71 100644 --- a/templates/result.html +++ b/templates/result.html @@ -1,226 +1,110 @@ +{% extends "base.html" %} - - - - 预测结果 +{% set active_page = "result" %} +{% block title %}预测结果 | 供水管道健康评估系统{% endblock %} - - - - - - - +{% block content %} +{% if result %} +
+
+

预测结果

+

文件:{{ result.original_filename }} · 生成于 {{ result.generated_at }}

+
+ +
- - -
-
-
-
-
- water_drop - 供水管道健康评估系统 -
- -
-
- notifications - help -
-
-
{{ current_user.username[:1]|upper }}
-
- - -
-
-
-
+
+
+
+

管道剩余寿命动态评估

+

生存概率随时间变化的阶梯曲线。

-
+
+ 生存概率阶梯图 +
+
+ 分析说明:{{ result.analysis_text }} +
+ -
- {% if result %} -
-
-
- description -
-
-
数据分析报告: {{ result.original_filename }}
-
基于上传数据生成的实时分析报告 • 生成于: {{ result.generated_at }}
-
-
-
-
check_circle数据源已验证
- 返回主页 -
+ -
-

预测结果

- + {% if result.importance_url %} +
+
+

模型输入因素重要性排序

+

各输入因素对预测结果的相对影响程度。

- -
-
-
-

供水管道健康状态评估等级

- -
- -
-
I级
(0, 0.2]
管道安全风险十分严重,需立刻进行抢修或更新改造
-
II级
(0.2, 0.4]
管道安全风险较为严重,需尽快安排检修及加频巡检
-
III级
(0.4, 0.6]
管道安全风险较低,需安排定期巡检
-
IV级
(0.6, 0.8]
管道安全风险较小,维持常规巡视
-
V级
(0.8, 1]
管道安全,维持常规巡视
-
- -
- - - - - - - - - {% for item in result.summary_rows %} - - - - - {% endfor %} - -
管道编号健康等级
{{ item.pipe_id }} - {{ item.grade_label }} -
-
- 共 {{ result.sample_count }} 个样本,显示 {{ result.summary_rows|length }} 条 - 查看完整样本列表 -
-
-
- -
-
-
-

管道剩余寿命动态评估

-

生存曲线拟合

-
-
- -
- 生存概率阶梯图 -
- -
- info -

分析说明:{{ result.analysis_text }}

-
-
+
+ 模型输入因素重要性排序图
+
+ {% endif %} - {% if result.importance_url %} -
-
-
-

模型输入因素重要性排序

-

各输入因素对预测结果的相对影响程度

-
-
-
- 模型输入因素重要性排序图 -
-
- insights -

说明:柱状图按重要性从高到低展示各输入因素对模型预测结果的相对贡献,数值为归一化占比,可用于辅助识别影响管道健康状态的关键因素。

-
-
- {% endif %} - {% else %} -
当前还没有预测结果,请先从主页上传文件并运行预测。
- {% endif %} -
- -
- 预测结果仅供参考 -
-
- - + +
+{% else %} +
+ monitoring +

当前还没有预测结果

+

请先从主页上传文件并运行预测。

+ + upload_file + 去上传数据 + +
+{% endif %} +{% endblock %} diff --git a/tests/test_auth_registration.py b/tests/test_auth_registration.py new file mode 100644 index 0000000..26e0d42 --- /dev/null +++ b/tests/test_auth_registration.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import re +import unittest +from datetime import datetime, timedelta +from tempfile import TemporaryDirectory + +from app import create_app +from app.config import Config +from app.extensions import db +from app.models import AppSetting, UploadRecord, User + + +class RegistrationRoutesTest(unittest.TestCase): + def create_test_app(self, temp_dir: str, *, allow_registration: bool): + class TestConfig(Config): + TESTING = True + SECRET_KEY = "test-secret" + SECRET_KEY_GENERATED = False + SQLALCHEMY_DATABASE_URI = f"sqlite:///{temp_dir}/test.db" + ALLOW_REGISTRATION = allow_registration + ADMIN_PASSWORD = None + + return create_app(TestConfig, load_model_on_start=False) + + def csrf_token_from(self, html: bytes) -> str: + match = re.search(rb'name="csrf_token" value="([^"]+)"', html) + self.assertIsNotNone(match) + return match.group(1).decode() + + def create_user(self, app, username: str, password: str, *, is_admin: bool = False) -> None: + with app.app_context(): + user = User(username=username, is_admin=is_admin) + user.set_password(password) + db.session.add(user) + db.session.commit() + + def add_upload_records(self, app, username: str, count: int) -> None: + with app.app_context(): + user = User.query.filter_by(username=username).one() + base_time = datetime(2026, 1, 1, 12, 0, 0) + for index in range(count): + db.session.add( + UploadRecord( + user_id=user.id, + original_filename=f"{username}-file-{index:02d}.xlsx", + saved_path=f"/tmp/{username}-original-{index:02d}.xlsx", + prediction_path=f"/tmp/{username}-prediction-{index:02d}.xlsx", + image_path=f"/tmp/{username}-image-{index:02d}.png", + upload_time=base_time + timedelta(minutes=index), + ) + ) + db.session.commit() + + def login(self, client, username: str, password: str) -> None: + response = client.get("/login") + token = self.csrf_token_from(response.data) + with client.session_transaction() as session: + captcha = session["captcha"] + + login_response = client.post( + "/login", + data={ + "csrf_token": token, + "username": username, + "password": password, + "captcha": captcha, + }, + ) + self.assertEqual(login_response.status_code, 302) + + def test_login_page_always_shows_register_entry_when_registration_is_closed(self) -> None: + with TemporaryDirectory() as temp_dir: + app = self.create_test_app(temp_dir, allow_registration=False) + + response = app.test_client().get("/login") + + self.assertEqual(response.status_code, 200) + self.assertIn('href="/register"', response.get_data(as_text=True)) + + def test_register_page_is_visible_but_disabled_when_registration_is_closed(self) -> None: + with TemporaryDirectory() as temp_dir: + app = self.create_test_app(temp_dir, allow_registration=False) + + response = app.test_client().get("/register") + html = response.get_data(as_text=True) + + self.assertEqual(response.status_code, 200) + self.assertIn("当前未开放自助注册", html) + self.assertIn("disabled", html) + + def test_register_post_does_not_create_user_when_registration_is_closed(self) -> None: + with TemporaryDirectory() as temp_dir: + app = self.create_test_app(temp_dir, allow_registration=False) + client = app.test_client() + token = self.csrf_token_from(client.get("/register").data) + + response = client.post( + "/register", + data={"csrf_token": token, "username": "new-user", "password": "secret123"}, + ) + + self.assertEqual(response.status_code, 403) + self.assertIn("当前未开放自助注册", response.get_data(as_text=True)) + with app.app_context(): + self.assertIsNone(User.query.filter_by(username="new-user").first()) + + def test_register_post_creates_user_when_registration_is_open(self) -> None: + with TemporaryDirectory() as temp_dir: + app = self.create_test_app(temp_dir, allow_registration=True) + client = app.test_client() + token = self.csrf_token_from(client.get("/register").data) + + response = client.post( + "/register", + data={"csrf_token": token, "username": "new-user", "password": "secret123"}, + ) + + self.assertEqual(response.status_code, 200) + self.assertIn("注册成功,请登录", response.get_data(as_text=True)) + with app.app_context(): + user = User.query.filter_by(username="new-user").first() + self.assertIsNotNone(user) + self.assertFalse(user.is_admin) + + def test_admin_can_enable_registration_from_admin_panel(self) -> None: + with TemporaryDirectory() as temp_dir: + app = self.create_test_app(temp_dir, allow_registration=False) + self.create_user(app, "admin", "secret123", is_admin=True) + client = app.test_client() + self.login(client, "admin", "secret123") + token = self.csrf_token_from(client.get("/admin").data) + + response = client.post( + "/admin/registration", + data={"csrf_token": token, "allow_registration": "on"}, + follow_redirects=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertIn("已开放用户自助注册", response.get_data(as_text=True)) + with app.app_context(): + self.assertTrue(AppSetting.get_bool("allow_registration")) + + register_page = client.get("/register").get_data(as_text=True) + self.assertNotIn("当前未开放自助注册", register_page) + + def test_admin_can_disable_registration_from_admin_panel(self) -> None: + with TemporaryDirectory() as temp_dir: + app = self.create_test_app(temp_dir, allow_registration=True) + self.create_user(app, "admin", "secret123", is_admin=True) + client = app.test_client() + self.login(client, "admin", "secret123") + token = self.csrf_token_from(client.get("/admin").data) + + response = client.post( + "/admin/registration", + data={"csrf_token": token}, + follow_redirects=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertIn("已关闭用户自助注册", response.get_data(as_text=True)) + with app.app_context(): + self.assertFalse(AppSetting.get_bool("allow_registration", True)) + + def test_history_page_paginates_upload_records(self) -> None: + with TemporaryDirectory() as temp_dir: + app = self.create_test_app(temp_dir, allow_registration=False) + self.create_user(app, "alice", "secret123") + self.add_upload_records(app, "alice", 12) + client = app.test_client() + self.login(client, "alice", "secret123") + + first_page = client.get("/history").get_data(as_text=True) + second_page = client.get("/history?page=2").get_data(as_text=True) + + self.assertIn("共 12 条", first_page) + self.assertIn("alice-file-11.xlsx", first_page) + self.assertIn("alice-file-02.xlsx", first_page) + self.assertNotIn("alice-file-01.xlsx", first_page) + self.assertIn("第 2 / 2 页", second_page) + self.assertIn("alice-file-01.xlsx", second_page) + self.assertIn("alice-file-00.xlsx", second_page) + + def test_admin_page_paginates_upload_records(self) -> None: + with TemporaryDirectory() as temp_dir: + app = self.create_test_app(temp_dir, allow_registration=False) + self.create_user(app, "admin", "secret123", is_admin=True) + self.create_user(app, "alice", "secret123") + self.add_upload_records(app, "alice", 12) + client = app.test_client() + self.login(client, "admin", "secret123") + + first_page = client.get("/admin").get_data(as_text=True) + second_page = client.get("/admin?page=2").get_data(as_text=True) + + self.assertIn("共 12 条", first_page) + self.assertIn("alice-file-11.xlsx", first_page) + self.assertIn("alice-file-02.xlsx", first_page) + self.assertNotIn("alice-file-01.xlsx", first_page) + self.assertIn("alice-file-01.xlsx", second_page) + self.assertIn("alice-file-00.xlsx", second_page) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_prediction.py b/tests/test_prediction.py index 6bd7512..8b51300 100644 --- a/tests/test_prediction.py +++ b/tests/test_prediction.py @@ -1,6 +1,8 @@ from __future__ import annotations import unittest +from pathlib import Path +from tempfile import TemporaryDirectory import pandas as pd @@ -13,9 +15,16 @@ from app.prediction import ( interpolate_probability, secure_upload_name, validate_input_frame, + write_prediction_workbook, ) +class DummyCurve: + def __init__(self, x: list[float], y: list[float]) -> None: + self.x = x + self.y = y + + class PredictionHelpersTest(unittest.TestCase): def test_secure_upload_name_accepts_chinese_filename(self) -> None: filename, suffix = secure_upload_name("管道数据.xlsx", "run123") @@ -44,6 +53,29 @@ class PredictionHelpersTest(unittest.TestCase): self.assertIn("缺少必要字段", ctx.exception.message) + def test_prediction_workbook_keeps_sample_data_in_one_sheet(self) -> None: + curves = [ + DummyCurve([1, 2], [0.9, 0.7]), + DummyCurve([1, 2], [0.8, 0.6]), + ] + summary_rows = [{"pipe_id": "P001"}, {"pipe_id": "P002"}] + summary_sheet_rows = [ + {"管道编号": "P001", "健康概率": 0.7}, + {"管道编号": "P002", "健康概率": 0.6}, + ] + + with TemporaryDirectory() as temp_dir: + output_path = Path(temp_dir) / "prediction.xlsx" + write_prediction_workbook(output_path, curves, summary_rows, summary_sheet_rows) + + workbook = pd.ExcelFile(output_path) + self.assertEqual(workbook.sheet_names, ["结果摘要", "样本数据"]) + + sample_data = pd.read_excel(output_path, sheet_name="样本数据") + self.assertEqual(len(sample_data), 4) + self.assertEqual(sample_data["管道编号"].tolist(), ["P001", "P001", "P002", "P002"]) + self.assertIn("风险概率", sample_data.columns) + if __name__ == "__main__": unittest.main()