Files
pipeline-lifetime/main.py
T

1390 lines
61 KiB
Python

# -*- coding: utf-8 -*-
"""
运行要求:
1. 同目录放置 my_survival_forest_model_quxi-10-0331.joblib
2. pip install flask flask_sqlalchemy flask_login pandas joblib matplotlib openpyxl xlsxwriter xlrd
3. python final_flask_app_0331_strict.py
"""
from __future__ import annotations
import logging
import os
import secrets
import sys
import uuid
from dataclasses import dataclass
from datetime import datetime
from io import BytesIO
from pathlib import Path
from typing import Any, Dict, List
import joblib
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from flask import (
Flask,
abort,
flash,
jsonify,
redirect,
render_template_string,
request,
send_file,
session,
url_for,
)
from flask_login import (
LoginManager,
UserMixin,
current_user,
login_required,
login_user,
logout_user,
)
from flask_sqlalchemy import SQLAlchemy
from matplotlib import font_manager, rcParams
from werkzeug.security import check_password_hash, generate_password_hash
from werkzeug.utils import secure_filename
CHINESE_FONT_CANDIDATES = [
"Noto Sans CJK SC",
"Noto Sans CJK JP",
"Noto Sans CJK TC",
"Source Han Sans SC",
"WenQuanYi Micro Hei",
"SimHei",
"Microsoft YaHei",
"Arial Unicode MS",
]
def configure_matplotlib_fonts() -> None:
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]
rcParams["font.sans-serif"] = selected_fonts + ["DejaVu Sans"]
configure_matplotlib_fonts()
rcParams["axes.unicode_minus"] = False
BASE_DIR = Path(__file__).resolve().parent
DATA_DIR = BASE_DIR / "data"
STATIC_DIR = BASE_DIR / "static"
UPLOAD_DIR = BASE_DIR / "uploads"
IMAGE_DIR = STATIC_DIR / "images"
FEATURES = [
"管材", "管径", "流速", "压力",
"温度", "降雨量", "位置",
"结构缺陷", "功能缺陷",
]
ID_COLUMN = "管道编号"
PIPE_AGE_COLUMN = "管龄"
TEMPLATE_COLUMNS = [
ID_COLUMN,
"管龄", "状态", "管材", "管径", "流速",
"压力", "温度", "降雨量", "位置",
"结构缺陷", "功能缺陷",
]
DATA_DIR.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
filename=str(DATA_DIR / "app.log"),
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
)
app = Flask(__name__)
def env_int(name: str, default: int) -> int:
try:
return int(os.environ.get(name, str(default)))
except ValueError:
logging.warning("%s 配置无效,使用默认值 %s", name, default)
return default
secret_key = os.environ.get("SECRET_KEY")
if not secret_key:
secret_key = secrets.token_hex(32)
logging.warning("未设置 SECRET_KEY,已生成临时密钥;服务重启后登录会话将失效。")
app.config["SECRET_KEY"] = secret_key
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{DATA_DIR / 'pipe_survival_0331.db'}"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["UPLOAD_FOLDER"] = str(UPLOAD_DIR)
app.config["MAX_CONTENT_LENGTH"] = env_int("MAX_UPLOAD_BYTES", 16 * 1024 * 1024)
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
db = SQLAlchemy(app)
login_manager = LoginManager(app)
login_manager.login_view = "login"
login_manager.login_message = "请先登录后再访问该页面。"
@app.errorhandler(413)
def handle_file_too_large(_exc):
max_mb = app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024)
if request.path == "/predict":
return jsonify({"error": f"文件过大,请上传 {max_mb}MB 以内的文件。"}), 413
return f"文件过大,请上传 {max_mb}MB 以内的文件。", 413
class User(UserMixin, db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(100), unique=True, nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
is_admin = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def set_password(self, password: str) -> None:
self.password_hash = generate_password_hash(password)
def check_password(self, password: str) -> bool:
return check_password_hash(self.password_hash, password)
class UploadRecord(db.Model):
__tablename__ = "upload_records"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
original_filename = db.Column(db.String(255), nullable=False)
saved_path = db.Column(db.String(500), nullable=False)
prediction_path = db.Column(db.String(500), nullable=False)
image_path = db.Column(db.String(500), nullable=False)
upload_time = db.Column(db.DateTime, default=datetime.utcnow)
user = db.relationship("User", backref=db.backref("uploads", lazy=True))
@login_manager.user_loader
def load_user(user_id: str):
return db.session.get(User, int(user_id))
def csrf_token() -> str:
token = session.get("_csrf_token")
if not token:
token = secrets.token_urlsafe(32)
session["_csrf_token"] = token
return token
def validate_csrf_token() -> bool:
expected = session.get("_csrf_token", "")
received = request.form.get("csrf_token") or request.headers.get("X-CSRFToken", "")
return bool(expected and received and secrets.compare_digest(expected, received))
def new_captcha() -> str:
alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
return "".join(secrets.choice(alphabet) for _ in range(5))
@app.before_request
def protect_csrf():
if request.method not in {"POST", "PUT", "PATCH", "DELETE"}:
return None
if validate_csrf_token():
return None
if request.path == "/predict":
return jsonify({"error": "CSRF 校验失败,请刷新页面后重试。"}), 400
abort(400)
@dataclass
class PipeSummary:
pipe_id: str
pipe_age: str
grade_label: str
def resource_path(relative_path: str) -> str:
try:
base_path = Path(sys._MEIPASS) # type: ignore[attr-defined]
except Exception:
base_path = BASE_DIR
return str((base_path / relative_path).resolve())
def safe_unlink(path: Path) -> None:
try:
path.unlink(missing_ok=True)
except OSError as exc:
logging.warning("删除文件失败 %s: %s", path, exc)
def ensure_dirs() -> None:
for path in [DATA_DIR, STATIC_DIR, IMAGE_DIR, UPLOAD_DIR]:
path.mkdir(parents=True, exist_ok=True)
def init_app() -> None:
ensure_dirs()
with app.app_context():
db.create_all()
admin_username = os.environ.get("ADMIN_USERNAME", "admin").strip() or "admin"
admin_password = os.environ.get("ADMIN_PASSWORD")
if admin_password:
admin = User.query.filter_by(username=admin_username).first()
if admin is None:
admin = User(username=admin_username, is_admin=True)
admin.set_password(admin_password)
db.session.add(admin)
else:
admin.is_admin = True
if not admin.check_password(admin_password):
admin.set_password(admin_password)
db.session.commit()
elif not User.query.filter_by(is_admin=True).first():
logging.warning("未设置 ADMIN_PASSWORD,跳过自动创建管理员账号。")
default_admin = User.query.filter_by(username="admin", is_admin=True).first()
if default_admin and default_admin.check_password("admin123"):
logging.warning("检测到默认管理员密码 admin123,请立即通过 ADMIN_PASSWORD 更新。")
try:
app.config["RSF_MODEL"] = load_model()
logging.info("模型加载成功")
except Exception as exc:
app.config["RSF_MODEL"] = None
logging.exception("模型加载失败: %s", exc)
def load_model():
model_path = resource_path("my_survival_forest_model_quxi-10-0331.joblib")
if not os.path.exists(model_path):
raise FileNotFoundError(f"未找到模型文件: {model_path}")
return joblib.load(model_path)
@app.context_processor
def inject_helpers():
def now_year() -> int:
return datetime.now().year
return {"feature_list": FEATURES, "now_year": now_year, "csrf_token": csrf_token}
BASE_TEMPLATE_HEAD = r"""
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@700;800&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: '#005EB8',
primaryDeep: '#0c4188',
page: '#f3f5f8',
card: '#ffffff',
line: '#e5e7eb',
textMain: '#0f172a',
textSub: '#64748b',
blueSoft: '#eaf3ff',
bluePanel: '#1d4f9a',
outline: '#c7ced8',
successSoft: '#e9f8ee',
successText: '#16a34a',
warnSoft: '#fff4e8',
warnText: '#c2410c',
dangerSoft: '#fff0f0',
dangerText: '#dc2626',
lowCard: '#f8fafc'
},
fontFamily: {
headline: ['Manrope', 'Inter', 'sans-serif'],
body: ['Inter', 'sans-serif']
},
boxShadow: {
soft: '0 24px 24px -12px rgba(24,28,30,.06)',
card: '0 10px 25px rgba(15, 23, 42, .06)'
}
}
}
}
</script>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
vertical-align: middle;
}
body { font-family: 'Inter', sans-serif; }
h1, h2, h3, h4 { font-family: 'Manrope', 'Inter', sans-serif; }
.dot-grid {
background-image: radial-gradient(circle at 1px 1px, rgba(148,163,184,.30) 1.2px, transparent 0);
background-size: 42px 42px;
}
.panel-frame {
box-shadow: none;
border: none;
}
.gradient-board {
background: linear-gradient(180deg, #2455a3 0%, #123e7d 100%);
}
.spinner {
width: 18px; height: 18px; border-radius: 9999px;
border: 2px solid rgba(255,255,255,.35); border-top-color: #fff;
animation: spin .75s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
"""
LOGIN_TEMPLATE = r"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<title>{{ '注册' if mode == 'register' else '登录' }} | </title>
""" + BASE_TEMPLATE_HEAD + r"""
</head>
<body class="bg-page min-h-screen text-textMain">
<div class="min-h-screen grid lg:grid-cols-[1.4fr_1fr]">
<section class="gradient-board hidden lg:flex flex-col justify-between px-16 py-14 text-white">
<div class="flex items-center gap-2 text-[16px] font-bold">
<span class="material-symbols-outlined">water_drop</span>
<span>供水管道健康评估系统</span>
</div>
<div>
<h1 class="text-[52px] leading-[1.5] font-extrabold tracking-tight">
供水管道健康状态<br>
与剩余寿命评估<br>
技术导则
</h1>
<p class="mt-6 text-white/75 text-[15px] max-w-[440px] leading-7">上传管网数据,自动生成健康状态评估、剩余寿命预测与输入因素重要性分析。</p>
</div>
<div class="text-white/50 text-[12px]">© {{ now_year() }} 供水管道健康评估系统</div>
</section>
<section class="bg-white flex flex-col justify-between min-h-screen">
<div class="flex-1 flex items-center px-7 sm:px-12 md:px-14 py-12">
<div class="w-full max-w-[360px] mx-auto">
<h2 class="text-center text-[44px] lg:text-[40px] font-extrabold tracking-tight mb-10">系统门户</h2>
<div class="flex items-center gap-8 text-[13px] font-semibold border-b border-slate-200 mb-7">
<a href="{{ url_for('login') }}" class="py-3 {{ 'text-primary border-b-2 border-primary' if mode == 'login' else 'text-slate-500' }}">登录</a>
<a href="{{ url_for('register') }}" class="py-3 {{ 'text-primary border-b-2 border-primary' if mode == 'register' else 'text-slate-500' }}">注册</a>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="space-y-3 mb-5">
{% for category, message in messages %}
<div class="rounded-xl px-4 py-3 text-sm border {{ 'bg-dangerSoft text-dangerText border-red-200' if category == 'error' else 'bg-blueSoft text-primary border-blue-200' }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% if mode == 'login' %}
<form method="post" action="{{ url_for('login') }}" class="space-y-5">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">身份识别 / 用户名</label>
<div class="relative">
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">person</span>
<input name="username" required class="w-full pl-11 pr-4 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0" placeholder="输入系统 ID" />
</div>
</div>
<div>
<div class="flex items-center justify-between mb-2">
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500">安全凭据</label>
<span class="text-[12px] text-primary font-semibold">找回密码</span>
</div>
<div class="relative">
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">lock</span>
<input name="password" type="password" required class="w-full pl-11 pr-4 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0" placeholder="••••••••" />
</div>
</div>
<div>
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">视觉验证</label>
<div class="grid grid-cols-[1fr_92px_28px] gap-3 items-center">
<div class="relative">
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">verified_user</span>
<input name="captcha" required class="w-full pl-11 pr-4 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0" placeholder="验证码" />
</div>
<div class="rounded-xl bg-blueSoft text-textMain border border-blue-100 h-[50px] flex items-center justify-center font-black tracking-[0.18em] italic">{{ captcha }}</div>
<a href="{{ url_for('login') }}" class="text-slate-500 hover:text-primary text-center">
<span class="material-symbols-outlined">refresh</span>
</a>
</div>
</div>
<label class="inline-flex items-center gap-2 text-sm text-slate-500">
<input type="checkbox" name="remember" class="rounded border-slate-300 text-primary focus:ring-primary" />
保持登录状态 24 小时
</label>
<button class="w-full mt-2 rounded-xl bg-primary hover:bg-primaryDeep text-white font-bold py-4 shadow-lg shadow-blue-200 flex items-center justify-center gap-2">
登录
<span class="material-symbols-outlined text-lg">arrow_forward</span>
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('register') }}" class="space-y-5">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">用户名</label>
<div class="relative">
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">badge</span>
<input name="username" required class="w-full pl-11 pr-4 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0" placeholder="设置用户名" />
</div>
</div>
<div>
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">密码</label>
<div class="relative">
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-lg">lock</span>
<input name="password" type="password" minlength="6" required class="w-full pl-11 pr-4 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0" placeholder="至少 6 位" />
</div>
</div>
<button class="w-full mt-2 rounded-xl bg-primary hover:bg-primaryDeep text-white font-bold py-4 shadow-lg shadow-blue-200 flex items-center justify-center gap-2">
注册
<span class="material-symbols-outlined text-lg">person_add</span>
</button>
</form>
{% endif %}
</div>
</div>
<footer class="border-t border-slate-200 bg-[#f8fafc] px-8 py-5">
<div class="flex justify-end gap-6 text-[11px] text-slate-500">
<span>系统状态</span>
<span>服务条款</span>
<span>API 文档</span>
</div>
</footer>
</section>
</div>
</body>
</html>
"""
DASHBOARD_TEMPLATE = r"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<title></title>
""" + BASE_TEMPLATE_HEAD + r"""
</head>
<body class="bg-page min-h-screen text-textMain">
<header class="sticky top-0 z-30 bg-white border-b border-line">
<div class="px-6 lg:px-10">
<div class="h-16 flex items-center justify-between gap-4">
<div class="flex items-center gap-10">
<div class="flex items-center gap-2 text-[18px] font-extrabold tracking-tight">
<span class="material-symbols-outlined text-primary">water_drop</span>
<span>供水管道健康评估系统</span>
</div>
<nav class="hidden md:flex items-center gap-7 text-[13px] font-semibold self-stretch">
<a href="{{ url_for('home') }}" class="flex items-center text-primary border-b-2 border-primary">主页</a>
<a href="{{ url_for('result_page') }}" class="flex items-center text-slate-500 hover:text-primary border-b-2 border-transparent">结果</a>
</nav>
</div>
<div class="flex items-center gap-4 text-slate-600">
<span class="material-symbols-outlined">notifications</span>
<span class="material-symbols-outlined">help</span>
<div class="h-8 w-px bg-slate-200"></div>
<div class="flex items-center gap-3 text-sm font-semibold">
<div class="w-8 h-8 rounded-full bg-primary flex items-center justify-center text-white text-xs">{{ current_user.username[:1]|upper }}</div>
<form method="post" action="{{ url_for('logout') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="text-textMain" type="submit">退出登录</button>
</form>
</div>
</div>
</div>
</div>
</header>
<main class="px-6 lg:px-10 py-8">
<section class="mb-8">
<h1 class="text-[40px] font-extrabold tracking-tight leading-tight">供水管道健康状态与剩余寿命评估技术导则</h1>
<p class="mt-2 text-textSub text-[15px]">上传您的数据以生成预测结果供参考。</p>
</section>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="space-y-3 mb-6">
{% for category, message in messages %}
<div class="rounded-xl px-4 py-3 text-sm border {{ 'bg-dangerSoft text-dangerText border-red-200' if category == 'error' else 'bg-blueSoft text-primary border-blue-200' }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div id="alertBox" class="hidden mb-6 rounded-xl px-4 py-3 text-sm border"></div>
<div id="mainGrid" class="grid grid-cols-1 xl:grid-cols-[1.45fr_1fr] gap-6 items-start">
<div>
<section class="bg-[#fafafa] rounded-[18px] border border-slate-200 p-7 shadow-soft">
<div class="flex items-center justify-between gap-4 mb-5">
<h2 class="text-[22px] font-bold flex items-center gap-2">
<span class="material-symbols-outlined text-primary">upload_file</span>
文件上传
</h2>
<a href="{{ url_for('download_template') }}" class="text-primary font-semibold text-sm flex items-center gap-1">
<span class="material-symbols-outlined text-sm">download</span>
下载模板
</a>
</div>
<form id="predictForm" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label id="dropZone" class="block relative rounded-[14px] border-2 border-dashed border-outline p-12 bg-white hover:border-primary hover:bg-blue-50/40 transition-all cursor-pointer text-center">
<input id="fileInput" name="file" type="file" accept=".csv,.xls,.xlsx" class="absolute inset-0 opacity-0 cursor-pointer" required>
<div class="flex flex-col items-center gap-4 pointer-events-none">
<div class="w-16 h-16 rounded-full bg-blueSoft text-primary flex items-center justify-center">
<span class="material-symbols-outlined text-3xl">cloud_upload</span>
</div>
<div>
<p class="text-[28px] font-bold">将您的数据文件拖放到此处</p>
<p class="text-[15px] text-textSub mt-1">支持 Excel (.xlsx) 和 CSV 文件</p>
<p id="selectedFileName" class="hidden mt-3 text-sm text-primary font-semibold"></p>
</div>
</div>
</label>
<div class="mt-8 flex justify-end">
<button id="submitBtn" class="bg-primary hover:bg-primaryDeep text-white px-8 py-4 rounded-[8px] font-bold flex items-center gap-3 shadow-lg shadow-blue-200 min-w-[170px] justify-center" type="submit">
<span id="submitText">分析并预测</span>
<span id="submitIcon" class="material-symbols-outlined">analytics</span>
</button>
</div>
</form>
</section>
<div class="mt-5">
<a href="{{ url_for('result_page') }}" class="bg-[#fafafa] rounded-[14px] border border-slate-200 p-6 flex items-center justify-between hover:bg-slate-50 hover:border-primary transition-colors">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-xl bg-blueSoft border border-blue-100 flex items-center justify-center text-primary">
<span class="material-symbols-outlined">monitoring</span>
</div>
<div>
<h3 class="font-bold text-[18px]">进入结果页</h3>
<p class="text-sm text-textSub">查看完整的健康评估与重要性分析</p>
</div>
</div>
<span class="material-symbols-outlined text-slate-400">chevron_right</span>
</a>
</div>
</div>
<aside>
<section class="bg-[#fafafa] rounded-[18px] border border-slate-200 p-7 shadow-soft">
<h2 class="text-[22px] font-bold flex items-center gap-2 mb-4">
<span class="material-symbols-outlined text-[#b45309]">info</span>
数据要求
</h2>
<p class="text-[14px] text-textSub leading-7 mb-6">为确保准确的生存分析,您上传的文件必须包含以下字段。请确保数据类型严格遵循模板。</p>
<div class="space-y-3">
<div class="bg-white rounded-xl border-l-4 border-primary p-4 border border-slate-200">
<div class="text-[11px] font-extrabold uppercase tracking-[0.18em] text-primary mb-1">必填基础信息</div>
<div class="font-semibold">管道编号、管龄、状态、管材、管径</div>
</div>
<div class="bg-white rounded-xl border-l-4 border-slate-500 p-4 border border-slate-200">
<div class="text-[11px] font-extrabold uppercase tracking-[0.18em] text-slate-500 mb-1">选填历史信息</div>
<div class="font-semibold">流速、压力、温度、降雨量、位置</div>
</div>
<div class="bg-white rounded-xl border-l-4 border-[#ea580c] p-4 border border-slate-200">
<div class="text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#ea580c] mb-1">选填内壁特征</div>
<div class="font-semibold">结构缺陷、功能缺陷</div>
</div>
<div class="bg-white rounded-xl border-l-4 border-red-500 p-4 border border-slate-200">
<div class="text-[11px] font-extrabold uppercase tracking-[0.18em] text-red-500 mb-1">选填运行环境</div>
<div class="font-semibold">字段名需与模板完全一致</div>
</div>
<div class="bg-blueSoft rounded-xl p-4 border border-blue-100 text-sm text-primary flex items-start gap-3">
<span class="material-symbols-outlined mt-0.5">tips_and_updates</span>
<span>上传数据类型请参考《供水管道健康状态与剩余寿命评估技术导则》附录内容</span>
</div>
</div>
</section>
</aside>
<section id="inlineResult" class="hidden bg-[#fafafa] rounded-[18px] border border-slate-200 p-6 shadow-soft self-stretch">
<div class="mb-4">
<h2 class="text-[24px] font-extrabold">最新预测结果</h2>
<p class="text-textSub text-sm mt-1">已生成预测图和 Excel 结果文件。</p>
</div>
<div class="bg-white rounded-xl border border-slate-200 p-4 mb-4">
<div class="text-xs font-semibold text-slate-500 mb-2">生存概率阶梯图</div>
<img id="resultImage" src="" alt="预测图" class="w-full h-auto object-contain">
</div>
<div id="resultImportanceWrap" class="hidden bg-white rounded-xl border border-slate-200 p-4 mb-4">
<div class="text-xs font-semibold text-slate-500 mb-2">模型输入因素重要性排序</div>
<img id="resultImportanceImage" src="" alt="重要性排序图" class="w-full h-auto object-contain">
</div>
<div class="bg-white rounded-xl border border-slate-200 p-5 mb-4">
<div class="text-xs uppercase tracking-[0.18em] text-slate-500 mb-3">结果摘要</div>
<div class="space-y-3 text-sm">
<div class="flex items-center justify-between gap-4"><span class="text-textSub">上传文件</span><span id="summaryFilename" class="font-semibold break-all text-right"></span></div>
<div class="flex items-center justify-between gap-4"><span class="text-textSub">预测样本数</span><span id="summaryCount" class="font-semibold">-</span></div>
<div class="flex items-center justify-between gap-4"><span class="text-textSub">模型</span><span class="font-semibold text-right break-all">my_survival_forest_model_quxi-10-0331</span></div>
</div>
</div>
<div class="flex flex-col gap-3">
<a id="resultPageBtn" href="{{ url_for('result_page') }}" class="px-5 py-3 rounded-xl border border-slate-200 bg-white font-semibold text-center">进入结果页</a>
<a id="excelBtn" href="#" class="px-5 py-3 rounded-xl bg-primary text-white font-semibold text-center">下载预测报告</a>
</div>
</section>
</div>
</main>
<footer class="px-6 lg:px-10 py-6 border-t border-line text-[12px] text-slate-500 flex items-center justify-between">
<span>预测结果仅供参考</span>
<div class="flex gap-6">
<span></span><span></span><span></span><span>文档</span>
</div>
</footer>
<script>
const form = document.getElementById('predictForm');
const fileInput = document.getElementById('fileInput');
const dropZone = document.getElementById('dropZone');
const selectedFileName = document.getElementById('selectedFileName');
const submitBtn = document.getElementById('submitBtn');
const submitText = document.getElementById('submitText');
const submitIcon = document.getElementById('submitIcon');
const alertBox = document.getElementById('alertBox');
const mainGrid = document.getElementById('mainGrid');
const inlineResult = document.getElementById('inlineResult');
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');
function showAlert(message, type='error') {
alertBox.classList.remove('hidden', 'bg-dangerSoft', 'text-dangerText', 'border-red-200', 'bg-blueSoft', 'text-primary', 'border-blue-200');
if (type === 'error') {
alertBox.classList.add('bg-dangerSoft', 'text-dangerText', 'border-red-200');
} else {
alertBox.classList.add('bg-blueSoft', 'text-primary', 'border-blue-200');
}
alertBox.textContent = message;
}
fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
if (!file) return;
selectedFileName.textContent = '已选择文件:' + file.name;
selectedFileName.classList.remove('hidden');
});
['dragenter', 'dragover'].forEach(evt => dropZone.addEventListener(evt, e => {
e.preventDefault();
dropZone.classList.add('border-primary', 'bg-blue-50');
}));
['dragleave', 'drop'].forEach(evt => dropZone.addEventListener(evt, e => {
e.preventDefault();
dropZone.classList.remove('border-primary', 'bg-blue-50');
}));
form.addEventListener('submit', async (e) => {
e.preventDefault();
alertBox.classList.add('hidden');
if (!fileInput.files.length) {
showAlert('请先选择要上传的文件。');
return;
}
submitBtn.disabled = true;
submitText.textContent = '预测中...';
submitIcon.outerHTML = '<span class="spinner" id="submitSpinner"></span>';
try {
const formData = new FormData(form);
const resp = await fetch('{{ url_for("predict") }}', { 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');
inlineResult.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (err) {
showAlert('请求失败,请检查后端服务是否正常。');
} finally {
submitBtn.disabled = false;
submitText.textContent = '分析并预测';
document.getElementById('submitSpinner')?.replaceWith(Object.assign(document.createElement('span'), { id: 'submitIcon', className: 'material-symbols-outlined', textContent: 'analytics' }));
}
});
</script>
</body>
</html>
"""
RESULT_TEMPLATE = r"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<title>预测结果 </title>
""" + BASE_TEMPLATE_HEAD + r"""
</head>
<body class="bg-page min-h-screen text-textMain">
<header class="sticky top-0 z-30 bg-white border-b border-line">
<div class="px-6 lg:px-10">
<div class="h-16 flex items-center justify-between gap-4">
<div class="flex items-center gap-10">
<div class="flex items-center gap-2 text-[18px] font-extrabold tracking-tight">
<span class="material-symbols-outlined text-primary">water_drop</span>
<span>供水管道健康评估系统</span>
</div>
<nav class="hidden md:flex items-center gap-7 text-[13px] font-semibold self-stretch">
<a href="{{ url_for('home') }}" class="flex items-center text-slate-500 hover:text-primary border-b-2 border-transparent">主页</a>
<a href="{{ url_for('result_page') }}" class="flex items-center text-primary border-b-2 border-primary">结果</a>
</nav>
</div>
<div class="flex items-center gap-4 text-slate-600">
<span class="material-symbols-outlined">notifications</span>
<span class="material-symbols-outlined">help</span>
<div class="h-8 w-px bg-slate-200"></div>
<div class="flex items-center gap-3 text-sm font-semibold">
<div class="w-8 h-8 rounded-full bg-primary flex items-center justify-center text-white text-xs">{{ current_user.username[:1]|upper }}</div>
<form method="post" action="{{ url_for('logout') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="text-textMain" type="submit">退出登录</button>
</form>
</div>
</div>
</div>
</div>
</header>
<main class="px-6 lg:px-10 py-8">
{% if result %}
<div class="rounded-[14px] bg-blueSoft border border-blue-100 p-4 flex items-center justify-between gap-4 mb-8">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-xl bg-primary text-white flex items-center justify-center">
<span class="material-symbols-outlined">description</span>
</div>
<div>
<div class="font-bold">数据分析报告: <span class="text-primary font-mono">{{ result.original_filename }}</span></div>
<div class="text-xs text-textSub mt-1">基于上传数据生成的实时分析报告 • 生成于: {{ result.generated_at }}</div>
</div>
</div>
<div class="flex items-center gap-4 text-xs font-semibold">
<div class="text-successText flex items-center gap-1"><span class="material-symbols-outlined text-base">check_circle</span>数据源已验证</div>
<a href="{{ url_for('home') }}" class="text-primary">返回主页</a>
</div>
</div>
<div class="flex flex-col md:flex-row md:items-end justify-between gap-5 mb-6">
<h1 class="text-[64px] leading-none font-extrabold tracking-tight">预测结果</h1>
<div class="flex flex-wrap gap-3">
<a href="{{ url_for('home') }}" class="px-6 py-4 rounded-xl border border-slate-200 bg-white font-semibold flex items-center gap-2">
<span class="material-symbols-outlined">refresh</span>重新运行分析
</a>
<a href="{{ result.excel_url }}" class="px-6 py-4 rounded-xl bg-primary text-white font-semibold flex items-center gap-2 shadow-lg shadow-blue-200">
<span class="material-symbols-outlined">download</span>导出预测报告 (Excel)
</a>
</div>
</div>
<div class="grid lg:grid-cols-[1.08fr_.92fr] gap-6 items-start">
<section class="bg-[#fafafa] rounded-[18px] border border-slate-200 p-6 shadow-soft">
<div class="flex items-center justify-between mb-4">
<h2 class="text-[28px] font-extrabold">供水管道健康状态评估等级</h2>
<span class="px-3 py-1 rounded-full text-[11px] font-bold bg-slate-100 text-slate-500"></span>
</div>
<div class="grid grid-cols-2 xl:grid-cols-5 gap-3 mb-5 text-sm">
<div class="rounded-xl bg-dangerSoft p-3 border border-red-100"><div class="text-dangerText font-extrabold">I级</div><div class="text-xs mt-1">(0, 0.2]</div><div class="text-xs mt-1 font-semibold">管道安全风险十分严重,需立刻进行抢修或更新改造</div></div>
<div class="rounded-xl bg-orange-50 p-3 border border-orange-100"><div class="text-orange-600 font-extrabold">II级</div><div class="text-xs mt-1">(0.2, 0.4]</div><div class="text-xs mt-1 font-semibold">管道安全风险较为严重,需尽快安排检修及加频巡检</div></div>
<div class="rounded-xl bg-amber-50 p-3 border border-amber-100"><div class="text-amber-600 font-extrabold">III级</div><div class="text-xs mt-1">(0.4, 0.6]</div><div class="text-xs mt-1 font-semibold">管道安全风险较低,需安排定期巡检</div></div>
<div class="rounded-xl bg-blue-50 p-3 border border-blue-100"><div class="text-blue-600 font-extrabold">IV级</div><div class="text-xs mt-1">(0.6, 0.8]</div><div class="text-xs mt-1 font-semibold">管道安全风险较小,维持常规巡视</div></div>
<div class="rounded-xl bg-blueSoft p-3 border border-blue-100"><div class="text-primary font-extrabold">V级</div><div class="text-xs mt-1">(0.8, 1]</div><div class="text-xs mt-1 font-semibold">管道安全,维持常规巡视</div></div>
</div>
<div class="rounded-2xl bg-white border border-slate-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-slate-500 text-xs uppercase tracking-[0.12em]">
<tr>
<th class="text-left px-5 py-4">管道编号</th>
<th class="text-left px-5 py-4">健康等级</th>
</tr>
</thead>
<tbody>
{% for item in result.summary_rows %}
<tr class="border-t border-slate-100">
<td class="px-5 py-4 font-semibold">{{ item.pipe_id }}</td>
<td class="px-5 py-4">
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold {{ item.grade_class }}">{{ item.grade_label }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="px-5 py-4 text-xs text-textSub flex items-center justify-between">
<span>共 {{ result.sample_count }} 个样本,显示 {{ result.summary_rows|length }} 条</span>
<a href="{{ result.excel_url }}" class="text-primary font-semibold">查看完整样本列表</a>
</div>
</div>
</section>
<section class="bg-[#fafafa] rounded-[18px] border border-slate-200 p-6 shadow-soft">
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<div>
<h2 class="text-[28px] font-extrabold">管道剩余寿命动态评估</h2>
<p class="text-sm text-textSub mt-1">生存曲线拟合</p>
</div>
</div>
<div class="bg-white rounded-xl border border-slate-200 p-4">
<img src="{{ result.image_url }}" alt="生存概率阶梯图" class="w-full h-auto object-contain">
</div>
<div class="mt-5 bg-blueSoft rounded-xl border border-blue-100 p-4 flex items-start gap-3 text-sm text-textSub leading-6">
<span class="material-symbols-outlined text-primary mt-0.5">info</span>
<p><span class="font-bold text-textMain">分析说明:</span>{{ result.analysis_text }}</p>
</div>
</section>
</div>
{% if result.importance_url %}
<section class="bg-[#fafafa] rounded-[18px] border border-slate-200 p-6 shadow-soft mt-6">
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<div>
<h2 class="text-[28px] font-extrabold">模型输入因素重要性排序</h2>
<p class="text-sm text-textSub mt-1">各输入因素对预测结果的相对影响程度</p>
</div>
</div>
<div class="bg-white rounded-xl border border-slate-200 p-4">
<img src="{{ result.importance_url }}" alt="模型输入因素重要性排序图" class="w-full h-auto object-contain">
</div>
<div class="mt-5 bg-blueSoft rounded-xl border border-blue-100 p-4 flex items-start gap-3 text-sm text-textSub leading-6">
<span class="material-symbols-outlined text-primary mt-0.5">insights</span>
<p><span class="font-bold text-textMain">说明:</span>柱状图按重要性从高到低展示各输入因素对模型预测结果的相对贡献,数值为归一化占比,可用于辅助识别影响管道健康状态的关键因素。</p>
</div>
</section>
{% endif %}
{% else %}
<div class="rounded-2xl bg-white border border-slate-200 p-10 text-center text-textSub">当前还没有预测结果,请先从主页上传文件并运行预测。</div>
{% endif %}
</main>
<footer class="px-6 lg:px-10 py-6 border-t border-line text-[12px] text-slate-500 flex items-center justify-between">
<span>预测结果仅供参考</span>
<div class="flex gap-6"><span></span><span></span><span></span><span></span></div>
</footer>
</body>
</html>
"""
ADMIN_TEMPLATE = r"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<title>管理台</title>
""" + BASE_TEMPLATE_HEAD + r"""
</head>
<body class="bg-page min-h-screen p-8 text-textMain">
<div class="max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-extrabold">管理员查看上传记录</h1>
<a href="{{ url_for('home') }}" class="px-4 py-2 rounded-lg border border-slate-200 bg-white">返回主页</a>
</div>
<div class="bg-white rounded-2xl border border-slate-200 overflow-hidden shadow-card">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-slate-500">
<tr>
<th class="text-left px-4 py-3">用户</th>
<th class="text-left px-4 py-3">原始文件</th>
<th class="text-left px-4 py-3">上传时间</th>
<th class="text-left px-4 py-3">下载</th>
</tr>
</thead>
<tbody>
{% for record in records %}
<tr class="border-t border-slate-100">
<td class="px-4 py-3">{{ record.user.username }}</td>
<td class="px-4 py-3">{{ record.original_filename }}</td>
<td class="px-4 py-3">{{ record.upload_time.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td class="px-4 py-3 flex gap-3">
<a class="text-primary" href="{{ url_for('download_file', record_id=record.id, file_type='original') }}">原始文件</a>
<a class="text-primary" href="{{ url_for('download_file', record_id=record.id, file_type='prediction') }}">预测结果</a>
</td>
</tr>
{% else %}
<tr><td colspan="4" class="px-4 py-8 text-center text-slate-500">暂无上传记录</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</body>
</html>
"""
HISTORY_TEMPLATE = r"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<title>预测历史</title>
""" + BASE_TEMPLATE_HEAD + r"""
</head>
<body class="bg-page min-h-screen p-8 text-textMain">
<div class="max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-extrabold">预测历史</h1>
<a href="{{ url_for('home') }}" class="px-4 py-2 rounded-lg border border-slate-200 bg-white">返回主页</a>
</div>
<div class="grid gap-4">
{% for record in records %}
<div class="bg-white rounded-2xl border border-slate-200 p-5 shadow-card flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<div class="font-bold">{{ record.original_filename }}</div>
<div class="text-sm text-textSub mt-1">{{ record.upload_time.strftime('%Y-%m-%d %H:%M:%S') }}</div>
</div>
<div class="flex flex-wrap gap-3">
<a class="px-4 py-2 rounded-lg border border-slate-200 bg-white" href="{{ url_for('download_file', record_id=record.id, file_type='original') }}">原始文件</a>
<a class="px-4 py-2 rounded-lg bg-primary text-white" href="{{ url_for('download_file', record_id=record.id, file_type='prediction') }}">预测结果</a>
</div>
</div>
{% else %}
<div class="bg-white rounded-2xl border border-slate-200 p-10 text-center text-textSub">还没有历史记录。</div>
{% endfor %}
</div>
</div>
</body>
</html>
"""
def grade_info(probability: float) -> tuple[str, str, str]:
if probability <= 0.2:
return ("I级", "管道安全风险十分严重,需立刻进行抢修或更新改造", "bg-dangerSoft text-dangerText")
if probability <= 0.4:
return ("II级", "管道安全风险较为严重,需尽快安排检修及加频巡检", "bg-orange-50 text-orange-600")
if probability <= 0.6:
return ("III级", "管道安全风险较低,需安排定期巡检", "bg-amber-50 text-amber-600")
if probability <= 0.8:
return ("IV级", "管道安全风险较小,维持常规巡视", "bg-blue-50 text-blue-600")
return ("V级", "管道安全,维持常规巡视", "bg-blueSoft text-primary")
def interpolate_probability(times: List[float], probs: List[float], target: float) -> float:
if not times:
return 0.0
if target <= times[0]:
return float(probs[0])
for idx in range(1, len(times)):
if times[idx] >= target:
return float(probs[idx])
return float(probs[-1])
def estimate_remaining_life(times: List[float], probs: List[float]) -> float:
for t, p in zip(times, probs):
if p <= 0.5:
return float(t)
return float(times[-1]) if times else 0.0
def make_analysis_text(summary_rows: List[Dict[str, Any]]) -> str:
if not summary_rows:
return "当前结果为空,暂无可供解释的样本。"
worst = min(summary_rows, key=lambda x: x["health_probability"])
best = max(summary_rows, key=lambda x: x["health_probability"])
return (
f"阶梯状曲线表示模型对不同管道随时间推移维持在安全健康状态概率的动态预测。"
)
def compute_feature_importance(model, x_test: "pd.DataFrame") -> "np.ndarray | None":
"""返回各输入因素的相对重要性(与 FEATURES 顺序一致,归一化为占比)。
优先使用模型自带的 feature_importances_;若不可用,则采用与标签无关的
置换重要性:打乱单个特征后观察模型风险评分的平均变化幅度。
"""
try:
importances = model.feature_importances_
except Exception:
importances = None
if importances is not None and len(importances) == len(FEATURES):
vals = np.asarray(importances, dtype=float)
else:
try:
baseline = np.asarray(model.predict(x_test), dtype=float)
except Exception as exc:
logging.exception("特征重要性基线预测失败: %s", exc)
return None
rng = np.random.default_rng(42)
n_repeats = 5
vals = np.zeros(len(FEATURES), dtype=float)
for j, feat in enumerate(FEATURES):
diffs = []
for _ in range(n_repeats):
x_perm = x_test.copy()
x_perm[feat] = rng.permutation(x_perm[feat].to_numpy())
try:
perm_pred = np.asarray(model.predict(x_perm), dtype=float)
except Exception:
perm_pred = baseline
diffs.append(float(np.mean(np.abs(perm_pred - baseline))))
vals[j] = float(np.mean(diffs)) if diffs else 0.0
vals = np.clip(vals, a_min=0.0, a_max=None)
total = float(vals.sum())
if total > 0:
vals = vals / total
return vals
def render_importance_chart(values: "np.ndarray", save_path: "Path") -> None:
from matplotlib.colors import LinearSegmentedColormap
# 仅保留有实际贡献的因素(占比 >= 0.05%,即不会显示为 0.0%);若全部过低则回退展示全部
keep = values >= 5e-4
if not bool(np.any(keep)):
keep = np.ones_like(values, dtype=bool)
kept_feats = [FEATURES[i] for i in range(len(FEATURES)) if keep[i]]
kept_vals = values[keep]
order = np.argsort(kept_vals)
sorted_feats = [kept_feats[k] for k in order]
sorted_vals = kept_vals[order]
n = len(sorted_vals)
height = max(3.0, 0.62 * n + 1.6)
fig, ax = plt.subplots(figsize=(9, height))
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)
ax.set_xlabel("相对重要性", fontsize=11, color="#475569")
ax.set_title("模型输入因素重要性排序", fontsize=15, fontweight="bold",
color="#0f172a", pad=14)
ax.grid(axis="x", color="#e2e8f0", linewidth=1, zorder=0)
ax.set_axisbelow(True)
for spine in ("top", "right", "left"):
ax.spines[spine].set_visible(False)
ax.spines["bottom"].set_color("#cbd5e1")
ax.tick_params(axis="y", length=0, labelsize=11)
ax.tick_params(axis="x", colors="#94a3b8", labelsize=9)
max_val = float(sorted_vals.max()) if n else 0.0
for bar, v in zip(bars, sorted_vals):
ax.text(bar.get_width() + max_val * 0.012,
bar.get_y() + bar.get_height() / 2,
f"{v:.1%}", va="center", ha="left",
fontsize=10, fontweight="bold", color="#1e293b")
if max_val > 0:
ax.set_xlim(0, max_val * 1.18)
fig.tight_layout()
fig.savefig(save_path, dpi=160, bbox_inches="tight")
plt.close(fig)
@app.route("/")
def index():
if current_user.is_authenticated:
return redirect(url_for("home"))
return redirect(url_for("login"))
@app.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return redirect(url_for("home"))
if request.method == "GET":
session["captcha"] = new_captcha()
return render_template_string(LOGIN_TEMPLATE, mode="login", captcha=session["captcha"])
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
captcha_input = request.form.get("captcha", "").strip().upper()
if captcha_input != session.get("captcha", ""):
flash("验证码错误", "error")
session["captcha"] = new_captcha()
return render_template_string(LOGIN_TEMPLATE, mode="login", captcha=session["captcha"]), 400
user = User.query.filter_by(username=username).first()
if not user or not user.check_password(password):
flash("用户名或密码错误", "error")
session["captcha"] = new_captcha()
return render_template_string(LOGIN_TEMPLATE, mode="login", captcha=session["captcha"]), 400
login_user(user, remember=bool(request.form.get("remember")))
return redirect(url_for("home"))
@app.route("/register", methods=["GET", "POST"])
def register():
if request.method == "GET":
return render_template_string(LOGIN_TEMPLATE, mode="register", captcha="")
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
if not username:
flash("用户名不能为空", "error")
return render_template_string(LOGIN_TEMPLATE, mode="register", captcha=""), 400
if len(password) < 6:
flash("密码至少需要 6 位", "error")
return render_template_string(LOGIN_TEMPLATE, mode="register", captcha=""), 400
if User.query.filter_by(username=username).first():
flash("用户名已存在", "error")
return render_template_string(LOGIN_TEMPLATE, mode="register", captcha=""), 400
user = User(username=username, is_admin=False)
user.set_password(password)
db.session.add(user)
db.session.commit()
flash("注册成功,请登录", "info")
session["captcha"] = new_captcha()
return render_template_string(LOGIN_TEMPLATE, mode="login", captcha=session["captcha"])
@app.route("/logout", methods=["POST"])
@login_required
def logout():
logout_user()
return redirect(url_for("login"))
@app.route("/home")
@login_required
def home():
return render_template_string(DASHBOARD_TEMPLATE)
@app.route("/history")
@login_required
def history_page():
records = UploadRecord.query.filter_by(user_id=current_user.id).order_by(UploadRecord.upload_time.desc()).all()
return render_template_string(HISTORY_TEMPLATE, records=records)
@app.route("/admin")
@login_required
def admin_dashboard():
if not current_user.is_admin:
abort(403)
records = UploadRecord.query.order_by(UploadRecord.upload_time.desc()).all()
return render_template_string(ADMIN_TEMPLATE, records=records)
@app.route("/download/<int:record_id>/<file_type>")
@login_required
def download_file(record_id: int, file_type: str):
record = UploadRecord.query.get_or_404(record_id)
if not (current_user.is_admin or current_user.id == record.user_id):
abort(403)
if file_type == "original":
file_path = record.saved_path
elif file_type == "prediction":
file_path = record.prediction_path
else:
abort(404)
if not os.path.exists(file_path):
abort(404)
return send_file(file_path, as_attachment=True)
@app.route("/download_template")
def download_template():
template_path = BASE_DIR / "example.xlsx"
if not template_path.exists():
abort(404)
return send_file(template_path, as_attachment=True, download_name="example.xlsx")
@app.route("/result")
@login_required
def result_page():
result = session.get("last_result")
return render_template_string(RESULT_TEMPLATE, result=result)
@app.route("/predict", methods=["POST"])
@login_required
def predict():
model = app.config.get("RSF_MODEL")
if model is None:
return jsonify({"error": "模型未成功加载,请检查 my_survival_forest_model_quxi-10-0331.joblib 文件。"}), 500
uploaded = request.files.get("file")
if uploaded is None or uploaded.filename == "":
return jsonify({"error": "未选择文件"}), 400
filename = secure_filename(uploaded.filename)
if not filename:
return jsonify({"error": "文件名无效"}), 400
if not filename.lower().endswith((".csv", ".xls", ".xlsx")):
return jsonify({"error": "不支持的格式,仅支持 CSV / XLS / XLSX"}), 400
user_dir = UPLOAD_DIR / f"user_{current_user.id}"
user_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
run_id = f"{timestamp}_{uuid.uuid4().hex[:8]}"
stem, ext = os.path.splitext(filename)
original_path = user_dir / f"{stem}_{run_id}{ext}"
uploaded.save(original_path)
try:
if ext.lower() == ".csv":
df = pd.read_csv(original_path)
else:
df = pd.read_excel(original_path)
except Exception as exc:
logging.exception("文件解析失败: %s", exc)
safe_unlink(original_path)
return jsonify({"error": "文件解析失败,请检查编码或表格格式。"}), 400
required_columns = [ID_COLUMN, *FEATURES]
missing = [col for col in required_columns if col not in df.columns]
if missing:
safe_unlink(original_path)
return jsonify({"error": f"缺少必要字段: {', '.join(missing)}"}), 400
x_test = df[FEATURES].copy()
try:
curves = model.predict_survival_function(x_test)
except Exception as exc:
logging.exception("预测失败: %s", exc)
safe_unlink(original_path)
return jsonify({"error": "模型预测失败,请检查输入字段类型是否正确。"}), 500
plt.figure(figsize=(10, 5.6))
summary_rows: List[Dict[str, Any]] = []
summary_sheet_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 = str(df.iloc[i][ID_COLUMN]) if pd.notna(df.iloc[i][ID_COLUMN]) else f"Pipe_{i+1:03d}"
pipe_age = f"{df.iloc[i][PIPE_AGE_COLUMN]} 年" if PIPE_AGE_COLUMN in df.columns and pd.notna(df.iloc[i][PIPE_AGE_COLUMN]) else "-"
health_probability = interpolate_probability(times, probs, 10)
remaining_life = estimate_remaining_life(times, probs)
grade_label, grade_desc, grade_class = grade_info(health_probability)
summary_rows.append({
"pipe_id": pipe_id,
"pipe_age": pipe_age,
"health_probability": health_probability,
"remaining_life": remaining_life,
"grade_label": grade_label,
"grade_desc": grade_desc,
"grade_class": grade_class,
})
summary_sheet_rows.append({
"管道编号": pipe_id,
"管龄": pipe_age,
"健康概率": health_probability,
"预计剩余寿命": remaining_life,
"健康等级": grade_label,
})
plt.step(times, probs, where="post", linewidth=2, label=pipe_id)
plt.xlabel("预测时间轴(年)")
plt.ylabel("生存概率")
plt.title("预测分析图")
plt.grid(alpha=0.18)
if len(summary_rows) <= 12:
plt.legend(loc="best", fontsize=8)
plt.tight_layout()
image_filename = f"plot_{current_user.id}_{run_id}.png"
image_path = IMAGE_DIR / image_filename
plt.savefig(image_path, dpi=160, bbox_inches="tight")
plt.close()
importance_filename = None
try:
importance_values = compute_feature_importance(model, x_test)
if importance_values is not None:
importance_filename = f"importance_{current_user.id}_{run_id}.png"
render_importance_chart(importance_values, IMAGE_DIR / importance_filename)
except Exception as exc:
logging.exception("生成特征重要性图失败: %s", exc)
importance_filename = None
excel_filename = f"{stem}_pre_{run_id}.xlsx"
excel_path = user_dir / excel_filename
with pd.ExcelWriter(excel_path, engine="xlsxwriter") 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)
record = UploadRecord(
user_id=current_user.id,
original_filename=filename,
saved_path=str(original_path),
prediction_path=str(excel_path),
image_path=str(image_path),
)
db.session.add(record)
db.session.commit()
analysis_text = make_analysis_text(summary_rows)
last_result = {
"original_filename": filename,
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"image_url": url_for("static", filename=f"images/{image_filename}"),
"importance_url": url_for("static", filename=f"images/{importance_filename}") if importance_filename else None,
"excel_url": url_for("download_file", record_id=record.id, file_type="prediction"),
"result_url": url_for("result_page"),
"sample_count": int(len(summary_rows)),
"summary_rows": summary_rows[:3],
"analysis_text": analysis_text,
}
session["last_result"] = last_result
return jsonify({
"message": "预测成功",
"image_url": last_result["image_url"],
"importance_url": last_result["importance_url"],
"excel_url": last_result["excel_url"],
"result_url": last_result["result_url"],
"sample_count": last_result["sample_count"],
"original_filename": filename,
})
if __name__ == "__main__":
init_app()
app.run(host="0.0.0.0", port=5005, debug=False, use_reloader=False)