feat: update assessment portal

This commit is contained in:
2026-07-06 16:53:06 +08:00
parent a75e857c71
commit f385f9747b
17 changed files with 1866 additions and 884 deletions
+6 -3
View File
@@ -7,7 +7,7 @@ from flask import Flask, abort, jsonify, request
from .config import Config, DATA_DIR, ensure_dirs from .config import Config, DATA_DIR, ensure_dirs
from .extensions import db, login_manager from .extensions import db, login_manager
from .models import User from .models import AppSetting, User
from .prediction import FEATURES, load_model from .prediction import FEATURES, load_model
from .security import csrf_token, validate_csrf_token from .security import csrf_token, validate_csrf_token
@@ -95,7 +95,10 @@ def register_app_hooks(app: Flask) -> None:
"feature_list": FEATURES, "feature_list": FEATURES,
"now_year": now_year, "now_year": now_year,
"csrf_token": csrf_token, "csrf_token": csrf_token,
"allow_registration": app.config["ALLOW_REGISTRATION"], "allow_registration": AppSetting.get_bool(
"allow_registration",
app.config["ALLOW_REGISTRATION"],
),
} }
@app.errorhandler(413) @app.errorhandler(413)
@@ -111,6 +114,6 @@ def register_app_hooks(app: Flask) -> None:
return None return None
if validate_csrf_token(): if validate_csrf_token():
return None return None
if request.path == "/predict": if request.path == "/predict" or request.headers.get("X-Requested-With") == "XMLHttpRequest":
return jsonify({"error": "CSRF 校验失败,请刷新页面后重试。"}), 400 return jsonify({"error": "CSRF 校验失败,请刷新页面后重试。"}), 400
abort(400) abort(400)
+25
View File
@@ -36,3 +36,28 @@ class UploadRecord(db.Model):
upload_time = db.Column(db.DateTime, default=datetime.utcnow) upload_time = db.Column(db.DateTime, default=datetime.utcnow)
user = db.relationship("User", backref=db.backref("uploads", lazy=True)) user = db.relationship("User", backref=db.backref("uploads", lazy=True))
class AppSetting(db.Model):
__tablename__ = "app_settings"
id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String(100), unique=True, nullable=False)
value = db.Column(db.String(255), nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@classmethod
def get_bool(cls, key: str, default: bool = False) -> bool:
setting = cls.query.filter_by(key=key).first()
if setting is None:
return default
return setting.value.strip().lower() in {"1", "true", "yes", "on"}
@classmethod
def set_bool(cls, key: str, value: bool) -> "AppSetting":
setting = cls.query.filter_by(key=key).first()
if setting is None:
setting = cls(key=key, value="")
db.session.add(setting)
setting.value = "true" if value else "false"
return setting
+74 -17
View File
@@ -23,6 +23,7 @@ from .config import IMAGE_DIR, UPLOAD_DIR
CHINESE_FONT_CANDIDATES = [ CHINESE_FONT_CANDIDATES = [
"Noto Sans CJK SC", "Noto Sans CJK SC",
"Noto Sans SC",
"Noto Sans CJK JP", "Noto Sans CJK JP",
"Noto Sans CJK TC", "Noto Sans CJK TC",
"Source Han Sans SC", "Source Han Sans SC",
@@ -32,6 +33,17 @@ CHINESE_FONT_CANDIDATES = [
"Arial Unicode MS", "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 = [ FEATURES = [
"管材", "管材",
"管径", "管径",
@@ -47,6 +59,7 @@ FEATURES = [
ID_COLUMN = "管道编号" ID_COLUMN = "管道编号"
PIPE_AGE_COLUMN = "管龄" PIPE_AGE_COLUMN = "管龄"
SUPPORTED_EXTENSIONS = {".csv", ".xls", ".xlsx"} SUPPORTED_EXTENSIONS = {".csv", ".xls", ".xlsx"}
CHINESE_FONT_PROP = None
class PredictionError(Exception): class PredictionError(Exception):
@@ -69,14 +82,34 @@ class PredictionArtifacts:
analysis_text: str 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} 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] 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["font.sans-serif"] = selected_fonts + ["DejaVu Sans"]
rcParams["axes.unicode_minus"] = False 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): 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]: def secure_upload_name(original_filename: str, run_id: str) -> tuple[str, str]:
suffix = Path(original_filename).suffix.lower() suffix = Path(original_filename).suffix.lower()
if suffix not in SUPPORTED_EXTENSIONS: if suffix not in SUPPORTED_EXTENSIONS:
raise PredictionError("不支持的格式,仅支持 CSV / XLS / XLSX") raise PredictionError("不支持的格式,仅支持逗号分隔值文件或电子表格文件")
safe_full_name = secure_filename(original_filename) safe_full_name = secure_filename(original_filename)
safe_stem = Path(safe_full_name).stem if safe_full_name else "" 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))) fig, ax = plt.subplots(figsize=(9, max(3.0, 0.62 * n + 1.6)))
cmap = LinearSegmentedColormap.from_list("brand", ["#7fb2e6", "#005EB8", "#0c4188"]) cmap = LinearSegmentedColormap.from_list("brand", ["#7fb2e6", "#005EB8", "#0c4188"])
colors = cmap(np.linspace(0.15, 1.0, n)) if n else None 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") font_kwargs = chinese_font_kwargs()
ax.set_title("模型输入因素重要性排序", fontsize=15, fontweight="bold", color="#0f172a", pad=14) 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.grid(axis="x", color="#e2e8f0", linewidth=1, zorder=0)
ax.set_axisbelow(True) ax.set_axisbelow(True)
for spine in ("top", "right", "left"): 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.spines["bottom"].set_color("#cbd5e1")
ax.tick_params(axis="y", length=0, labelsize=11) ax.tick_params(axis="y", length=0, labelsize=11)
ax.tick_params(axis="x", colors="#94a3b8", labelsize=9) 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 max_val = float(sorted_vals.max()) if n else 0.0
for bar, value in zip(bars, sorted_vals): 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, fontsize=10,
fontweight="bold", fontweight="bold",
color="#1e293b", color="#1e293b",
**font_kwargs,
) )
if max_val > 0: if max_val > 0:
ax.set_xlim(0, max_val * 1.18) 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.step(times, probs, where="post", linewidth=2, label=pipe_id)
plt.xlabel("预测时间轴(年)") font_kwargs = chinese_font_kwargs()
plt.ylabel("生存概率") plt.xlabel("预测时间轴(年)", **font_kwargs)
plt.title("预测分析图") plt.ylabel("生存概率", **font_kwargs)
plt.title("预测分析图", **font_kwargs)
plt.grid(alpha=0.18) plt.grid(alpha=0.18)
if len(summary_rows) <= 12: 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.tight_layout()
plt.savefig(image_path, dpi=160, bbox_inches="tight") plt.savefig(image_path, dpi=160, bbox_inches="tight")
plt.close() plt.close()
@@ -370,11 +416,22 @@ def write_prediction_workbook(
summary_rows: list[dict[str, Any]], summary_rows: list[dict[str, Any]],
summary_sheet_rows: list[dict[str, Any]], summary_sheet_rows: list[dict[str, Any]],
) -> None: ) -> 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) pd.DataFrame(summary_sheet_rows).to_excel(writer, sheet_name="结果摘要", index=False)
for i, curve in enumerate(curves): pd.DataFrame(sample_data_rows).to_excel(writer, sheet_name="样本数据", index=False)
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)
+92 -10
View File
@@ -17,14 +17,32 @@ from flask import (
url_for, url_for,
) )
from flask_login import current_user, login_required, login_user, logout_user from flask_login import current_user, login_required, login_user, logout_user
from sqlalchemy.orm import joinedload
from .config import BASE_DIR from .config import BASE_DIR
from .extensions import db from .extensions import db
from .models import UploadRecord, User from .models import AppSetting, UploadRecord, User
from .prediction import PredictionError, run_prediction from .prediction import PredictionError, run_prediction
from .security import new_captcha from .security import new_captcha
bp = Blueprint("main", __name__) 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("/") @bp.route("/")
@@ -64,12 +82,13 @@ def login():
@bp.route("/register", methods=["GET", "POST"]) @bp.route("/register", methods=["GET", "POST"])
def register(): def register():
if not current_app.config["ALLOW_REGISTRATION"]:
abort(404)
if request.method == "GET": if request.method == "GET":
return render_template("login.html", mode="register", captcha="") 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() username = request.form.get("username", "").strip()
password = request.form.get("password", "") password = request.form.get("password", "")
@@ -108,12 +127,19 @@ def home():
@bp.route("/history") @bp.route("/history")
@login_required @login_required
def history_page(): def history_page():
records = ( page = requested_page()
pagination = (
UploadRecord.query.filter_by(user_id=current_user.id) UploadRecord.query.filter_by(user_id=current_user.id)
.order_by(UploadRecord.upload_time.desc()) .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") @bp.route("/admin")
@@ -121,8 +147,44 @@ def history_page():
def admin_dashboard(): def admin_dashboard():
if not current_user.is_admin: if not current_user.is_admin:
abort(403) abort(403)
records = UploadRecord.query.order_by(UploadRecord.upload_time.desc()).all() page = requested_page()
return render_template("admin.html", records=records) 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>") @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") 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") @bp.route("/result")
@login_required @login_required
def result_page(): def result_page():
@@ -197,7 +279,7 @@ def predict():
"excel_url": url_for("main.download_file", record_id=record.id, file_type="prediction"), "excel_url": url_for("main.download_file", record_id=record.id, file_type="prediction"),
"result_url": url_for("main.result_page"), "result_url": url_for("main.result_page"),
"sample_count": int(artifacts.sample_count), "sample_count": int(artifacts.sample_count),
"summary_rows": artifacts.summary_rows[:3], "summary_rows": artifacts.summary_rows[:6],
"analysis_text": artifacts.analysis_text, "analysis_text": artifacts.analysis_text,
} }
session["last_result"] = last_result session["last_result"] = last_result
+1 -1
View File
@@ -4,4 +4,4 @@ app = create_app()
if __name__ == "__main__": 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)
+142 -30
View File
@@ -6,46 +6,159 @@ const submitBtn = document.getElementById('submitBtn');
const submitText = document.getElementById('submitText'); const submitText = document.getElementById('submitText');
let submitIcon = document.getElementById('submitIcon'); let submitIcon = document.getElementById('submitIcon');
const alertBox = document.getElementById('alertBox'); 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 inlineResult = document.getElementById('inlineResult');
const resultPlaceholder = document.getElementById('resultPlaceholder');
const resultContent = document.getElementById('resultContent');
const resultImage = document.getElementById('resultImage'); const resultImage = document.getElementById('resultImage');
const resultImportanceWrap = document.getElementById('resultImportanceWrap'); const resultImportanceWrap = document.getElementById('resultImportanceWrap');
const resultImportanceImage = document.getElementById('resultImportanceImage'); const resultImportanceImage = document.getElementById('resultImportanceImage');
const excelBtn = document.getElementById('excelBtn'); const excelBtn = document.getElementById('excelBtn');
const resultPageBtn = document.getElementById('resultPageBtn'); const resultPageBtn = document.getElementById('resultPageBtn');
const summaryFilename = document.getElementById('summaryFilename'); const homeStateKey = 'pipelineLifetime.homeState';
const summaryCount = document.getElementById('summaryCount'); let selectedFile = null;
let alertTimer = null;
let alertHideTimer = null;
function showAlert(message, type='error') { function hideAlert() {
alertBox.classList.remove('hidden', 'bg-dangerSoft', 'text-dangerText', 'border-red-200', 'bg-blueSoft', 'text-primary', 'border-blue-200'); 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') { 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 { } 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', () => { fileInput.addEventListener('change', () => {
const file = fileInput.files[0]; const file = fileInput.files[0];
if (!file) return; if (!file) return;
selectedFileName.textContent = '已选择文件:' + file.name; updateSelectedFile(file);
selectedFileName.classList.remove('hidden');
}); });
['dragenter', 'dragover'].forEach(evt => dropZone.addEventListener(evt, e => { ['dragenter', 'dragover'].forEach(evt => dropZone.addEventListener(evt, e => {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
dropZone.classList.add('border-primary', 'bg-blue-50'); dropZone.classList.add('border-primary', 'bg-blue-50');
})); }));
['dragleave', 'drop'].forEach(evt => dropZone.addEventListener(evt, e => {
dropZone.addEventListener('dragleave', e => {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
dropZone.classList.remove('border-primary', 'bg-blue-50'); 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) => { form.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
alertBox.classList.add('hidden'); hideAlert();
if (!fileInput.files.length) { if (!selectedFile) {
showAlert('请先选择要上传的文件'); 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; return;
} }
@@ -55,27 +168,24 @@ form.addEventListener('submit', async (e) => {
try { try {
const formData = new FormData(form); const formData = new FormData(form);
formData.set('file', selectedFile);
const resp = await fetch(form.action, { method: 'POST', body: formData }); const resp = await fetch(form.action, { method: 'POST', body: formData });
const data = await resp.json(); const data = await resp.json();
if (!resp.ok) { if (!resp.ok) {
showAlert(data.error || '预测失败,请稍后重试。'); showAlert(data.error || '预测失败,请稍后重试。');
return; return;
} }
resultImage.src = data.image_url; renderResult(data);
if (data.importance_url) { saveHomeState({
resultImportanceImage.src = data.importance_url; selectedFilename: data.original_filename || selectedFile.name,
resultImportanceWrap.classList.remove('hidden'); result: {
} else { image_url: data.image_url,
resultImportanceWrap.classList.add('hidden'); importance_url: data.importance_url || '',
} excel_url: data.excel_url,
excelBtn.href = data.excel_url; result_url: data.result_url
resultPageBtn.href = data.result_url; }
summaryFilename.textContent = data.original_filename; });
summaryCount.textContent = data.sample_count; showAlert('预测完成,已生成图表与电子表格报告。', 'success');
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' }); inlineResult.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (err) { } catch (err) {
showAlert('请求失败,请检查后端服务是否正常。'); showAlert('请求失败,请检查后端服务是否正常。');
@@ -87,3 +197,5 @@ form.addEventListener('submit', async (e) => {
submitIcon = restoredIcon; submitIcon = restoredIcon;
} }
}); });
restoreHomeState();
+45
View File
@@ -0,0 +1,45 @@
{% macro render_pagination(pagination, endpoint) %}
{% if pagination.total %}
<nav class="flex flex-col gap-3 border-t border-line px-5 py-4 sm:flex-row sm:items-center sm:justify-between" aria-label="分页">
<div class="text-sm text-textSub">
<span class="font-bold text-textMain">{{ pagination.total }}</span> 条记录,
<span class="font-bold text-textMain">{{ pagination.page }}</span> / {{ pagination.pages }} 页
</div>
{% if pagination.pages > 1 %}
<div class="flex flex-wrap items-center gap-2">
{% set prev_page = pagination.prev_num if pagination.has_prev else pagination.page %}
<a
class="ui-btn ui-btn-sm ui-btn-secondary {{ 'pointer-events-none opacity-50' if not pagination.has_prev }}"
href="{{ url_for(endpoint, page=prev_page) }}"
aria-disabled="{{ 'false' if pagination.has_prev else 'true' }}"
>
<span class="material-symbols-outlined text-lg">chevron_left</span>
上一页
</a>
<div class="flex items-center gap-1">
{% for page in pagination.iter_pages(left_edge=1, left_current=1, right_current=2, right_edge=1) %}
{% if page %}
<a
class="inline-flex h-10 min-w-10 items-center justify-center rounded-md border px-3 text-sm font-bold {{ 'border-primary bg-primary text-white' if page == pagination.page else 'border-line bg-white text-slate-600 hover:border-primary hover:text-primary' }}"
href="{{ url_for(endpoint, page=page) }}"
aria-current="{{ 'page' if page == pagination.page else 'false' }}"
>{{ page }}</a>
{% else %}
<span class="inline-flex h-10 min-w-10 items-center justify-center text-sm font-bold text-textSub">...</span>
{% endif %}
{% endfor %}
</div>
{% set next_page = pagination.next_num if pagination.has_next else pagination.page %}
<a
class="ui-btn ui-btn-sm ui-btn-secondary {{ 'pointer-events-none opacity-50' if not pagination.has_next }}"
href="{{ url_for(endpoint, page=next_page) }}"
aria-disabled="{{ 'false' if pagination.has_next else 'true' }}"
>
下一页
<span class="material-symbols-outlined text-lg">chevron_right</span>
</a>
</div>
{% endif %}
</nav>
{% endif %}
{% endmacro %}
+123 -104
View File
@@ -1,110 +1,129 @@
{% extends "base.html" %}
{% from "_pagination.html" import render_pagination %}
<!DOCTYPE html> {% set active_page = "admin" %}
<html lang="zh-CN"> {% block title %}管理台 | 供水管道健康评估系统{% endblock %}
<head>
<title>管理台</title>
<meta charset="utf-8" /> {% block content %}
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script> <div>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@700;800&display=swap" rel="stylesheet" /> <h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">管理台</h1>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" /> <p class="mt-2 text-sm text-textSub">管理系统注册状态,查看所有用户的上传文件和预测结果。</p>
<script> </div>
tailwind.config = { <a href="{{ url_for('main.home') }}" class="ui-btn ui-btn-secondary">
darkMode: 'class', <span class="material-symbols-outlined text-lg">arrow_back</span>
theme: { 返回主页
extend: { </a>
colors: { </div>
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>
</head> <section class="mb-6 rounded-lg border border-line bg-white p-5 shadow-panel">
<body class="bg-page min-h-screen p-8 text-textMain"> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="max-w-7xl mx-auto"> <div>
<div class="flex items-center justify-between mb-6"> <h2 class="text-lg font-extrabold tracking-tight">用户注册</h2>
<h1 class="text-3xl font-extrabold">管理员查看上传记录</h1> <p class="mt-1 text-sm text-textSub">
<a href="{{ url_for('main.home') }}" class="px-4 py-2 rounded-lg border border-slate-200 bg-white">返回主页</a> 当前状态:<span id="registrationStatus" class="font-bold {{ 'text-successText' if registration_allowed else 'text-slate-600' }}">{{ '已开放' if registration_allowed else '已关闭' }}</span>
</p>
</div> </div>
<div class="bg-white rounded-2xl border border-slate-200 overflow-hidden shadow-card"> <form id="registrationForm" method="post" action="{{ url_for('main.update_registration_setting') }}" class="flex items-center gap-3">
<table class="w-full text-sm"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<thead class="bg-slate-50 text-slate-500"> <label class="inline-flex items-center gap-2 text-sm font-semibold text-slate-600">
<tr> <input id="registrationToggle" type="checkbox" name="allow_registration" class="rounded border-slate-300 text-primary focus:ring-primary" {{ 'checked' if registration_allowed }}>
<th class="text-left px-4 py-3">用户</th> 允许自助注册
<th class="text-left px-4 py-3">原始文件</th> </label>
<th class="text-left px-4 py-3">上传时间</th> <button id="registrationSubmit" type="submit" class="ui-btn ui-btn-sm ui-btn-primary">
<th class="text-left px-4 py-3">下载</th> <span class="material-symbols-outlined text-lg">save</span>
</tr> <span id="registrationSubmitText">保存</span>
</thead> </button>
<tbody> </form>
{% for record in records %} </div>
<tr class="border-t border-slate-100"> </section>
<td class="px-4 py-3">{{ record.user.username }}</td>
<td class="px-4 py-3">{{ record.original_filename }}</td> <section class="flex min-h-[760px] flex-col overflow-hidden rounded-lg border border-line bg-white shadow-panel">
<td class="px-4 py-3">{{ record.upload_time.strftime('%Y-%m-%d %H:%M:%S') }}</td> <div class="border-b border-line px-5 py-4">
<td class="px-4 py-3 flex gap-3"> <div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<a class="text-primary" href="{{ url_for('main.download_file', record_id=record.id, file_type='original') }}">原始文件</a> <h2 class="text-lg font-extrabold">上传记录</h2>
<a class="text-primary" href="{{ url_for('main.download_file', record_id=record.id, file_type='prediction') }}">预测结果</a> {% if pagination.total %}
</td> <span class="text-sm text-textSub">共 {{ pagination.total }} 条</span>
</tr> {% endif %}
{% else %}
<tr><td colspan="4" class="px-4 py-8 text-center text-slate-500">暂无上传记录</td></tr>
{% endfor %}
</tbody>
</table>
</div> </div>
</div> </div>
</body> <div class="flex-1 overflow-x-auto">
</html> <table class="min-w-full text-sm">
<thead class="bg-slate-50 text-xs font-bold uppercase tracking-[0.12em] text-textSub">
<tr>
<th class="px-5 py-4 text-left">用户</th>
<th class="px-5 py-4 text-left">原始文件</th>
<th class="px-5 py-4 text-left">上传时间</th>
<th class="px-5 py-4 text-left">下载</th>
</tr>
</thead>
<tbody class="divide-y divide-line">
{% for record in records %}
<tr class="hover:bg-slate-50">
<td class="px-5 py-4 font-semibold">{{ record.user.username }}</td>
<td class="max-w-[420px] truncate px-5 py-4">{{ record.original_filename }}</td>
<td class="px-5 py-4 text-textSub">{{ record.upload_time.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td class="px-5 py-4">
<div class="flex flex-wrap gap-3">
<a class="font-bold text-primary" href="{{ url_for('main.download_file', record_id=record.id, file_type='original') }}">原始文件</a>
<a class="font-bold text-primary" href="{{ url_for('main.download_file', record_id=record.id, file_type='prediction') }}">预测结果</a>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="px-5 py-12 text-center text-textSub">暂无上传记录</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{{ render_pagination(pagination, 'main.admin_dashboard') }}
</section>
{% endblock %}
{% block scripts %}
<script>
(() => {
const form = document.getElementById('registrationForm');
const toggle = document.getElementById('registrationToggle');
const status = document.getElementById('registrationStatus');
const submit = document.getElementById('registrationSubmit');
const submitText = document.getElementById('registrationSubmitText');
if (!form || !toggle || !status || !submit || !submitText) return;
form.addEventListener('submit', async (event) => {
event.preventDefault();
submit.disabled = true;
submitText.textContent = '保存中';
try {
const response = await fetch(form.action, {
method: 'POST',
body: new FormData(form),
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
const data = await response.json();
if (!response.ok) {
window.showAppNotification?.(data.error || '保存失败,请刷新页面后重试。', 'error', '保存失败');
return;
}
toggle.checked = Boolean(data.registration_allowed);
status.textContent = data.status_label;
status.classList.toggle('text-successText', data.registration_allowed);
status.classList.toggle('text-slate-600', !data.registration_allowed);
window.showAppNotification?.(data.message, 'info');
} catch (error) {
window.showAppNotification?.('请求失败,请检查后端服务是否正常。', 'error', '保存失败');
} finally {
submit.disabled = false;
submitText.textContent = '保存';
}
});
})();
</script>
{% endblock %}
+294
View File
@@ -0,0 +1,294 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<title>{% block title %}供水管道健康评估系统{% endblock %}</title>
<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 = {
theme: {
extend: {
colors: {
primary: '#005EB8',
primaryDeep: '#0c4188',
page: '#f3f5f8',
card: '#ffffff',
line: '#e2e8f0',
textMain: '#0f172a',
textSub: '#64748b',
blueSoft: '#eaf3ff',
outline: '#c7ced8',
successSoft: '#e9f8ee',
successText: '#16a34a',
dangerSoft: '#fff0f0',
dangerText: '#dc2626'
},
fontFamily: {
headline: ['Manrope', 'Inter', 'sans-serif'],
body: ['Inter', 'sans-serif']
},
boxShadow: {
soft: '0 18px 34px rgba(15, 23, 42, .06)',
panel: '0 1px 2px rgba(15, 23, 42, .04)'
}
}
}
}
</script>
<style>
body { font-family: 'Inter', sans-serif; }
h1, h2, h3, h4 { font-family: 'Manrope', 'Inter', sans-serif; }
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
vertical-align: middle;
}
.ui-btn {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
gap: .5rem;
min-height: 44px;
height: 44px;
border-radius: .375rem;
border: 1px solid transparent;
padding: 0 1rem;
font-size: .875rem;
font-weight: 700;
line-height: 1;
white-space: nowrap;
transition: background-color .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease;
}
.ui-btn-sm {
min-height: 40px;
height: 40px;
padding: 0 .75rem;
}
.ui-btn-primary {
background: #005EB8;
color: #fff;
box-shadow: 0 18px 34px rgba(15, 23, 42, .06);
}
.ui-btn-primary:hover { background: #0c4188; }
.ui-btn-secondary {
border-color: #e2e8f0;
background: #fff;
color: #334155;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
}
.ui-btn-secondary:hover {
border-color: #005EB8;
color: #005EB8;
}
.ui-btn:disabled,
.ui-btn[aria-disabled="true"] {
cursor: not-allowed;
box-shadow: none;
opacity: .7;
}
.ui-btn-primary:disabled { background: #cbd5e1; }
.ui-btn-icon,
.ui-btn .material-symbols-outlined {
display: inline-flex;
width: 20px;
height: 20px;
flex: 0 0 20px;
align-items: center;
justify-content: center;
font-size: 20px;
line-height: 1;
}
.ui-icon-btn {
box-sizing: border-box;
display: inline-flex;
width: 28px;
height: 28px;
flex: 0 0 28px;
align-items: center;
justify-content: center;
border-radius: .375rem;
line-height: 1;
transition: background-color .15s ease, color .15s ease;
}
.ui-action-row {
box-sizing: border-box;
display: flex;
min-height: 44px;
align-items: center;
justify-content: space-between;
gap: .75rem;
border-radius: .375rem;
border: 1px solid #e2e8f0;
padding: 0 .75rem;
font-size: .875rem;
font-weight: 700;
line-height: 1;
transition: border-color .15s ease, color .15s ease;
}
.ui-action-row:hover {
border-color: #005EB8;
color: #005EB8;
}
.spinner {
display: inline-flex;
width: 20px;
height: 20px;
flex: 0 0 20px;
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>
{% block head_extra %}{% endblock %}
</head>
<body class="flex min-h-screen flex-col bg-page text-textMain antialiased">
<header class="sticky top-0 z-30 border-b border-line bg-white/95 backdrop-blur">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="h-16 flex items-center justify-between gap-4">
<a href="{{ url_for('main.home') }}" class="flex min-w-0 items-center gap-2 text-[16px] font-extrabold tracking-tight">
<span class="material-symbols-outlined text-primary">water_drop</span>
<span class="truncate">供水管道健康评估系统</span>
</a>
<nav class="hidden md:flex h-full items-center gap-1 text-sm font-semibold">
<a href="{{ url_for('main.home') }}" class="flex h-full items-center px-3 {{ 'text-primary border-b-2 border-primary' if active_page == 'home' else 'text-slate-500 border-b-2 border-transparent hover:text-primary' }}">主页</a>
<a href="{{ url_for('main.result_page') }}" class="flex h-full items-center px-3 {{ 'text-primary border-b-2 border-primary' if active_page == 'result' else 'text-slate-500 border-b-2 border-transparent hover:text-primary' }}">结果</a>
<a href="{{ url_for('main.history_page') }}" class="flex h-full items-center px-3 {{ 'text-primary border-b-2 border-primary' if active_page == 'history' else 'text-slate-500 border-b-2 border-transparent hover:text-primary' }}">历史</a>
<a href="{{ url_for('main.reference_page') }}" class="flex h-full items-center px-3 {{ 'text-primary border-b-2 border-primary' if active_page == 'reference' else 'text-slate-500 border-b-2 border-transparent hover:text-primary' }}">文档</a>
{% if current_user.is_admin %}
<a href="{{ url_for('main.admin_dashboard') }}" class="flex h-full items-center px-3 {{ 'text-primary border-b-2 border-primary' if active_page == 'admin' else 'text-slate-500 border-b-2 border-transparent hover:text-primary' }}">管理</a>
{% endif %}
</nav>
<div class="flex items-center gap-3">
<div class="hidden sm:flex items-center gap-2 text-sm font-semibold text-slate-600">
<div class="h-8 w-8 rounded-full bg-primary text-white flex items-center justify-center text-xs">{{ current_user.username[:1]|upper }}</div>
<span class="max-w-[120px] truncate">{{ current_user.username }}</span>
</div>
<form method="post" action="{{ url_for('main.logout') }}" data-clear-home-state>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="ui-btn ui-btn-sm ui-btn-secondary font-semibold text-slate-600" type="submit">退出</button>
</form>
</div>
</div>
</div>
<nav class="md:hidden border-t border-line bg-white">
<div class="mx-auto max-w-7xl px-2 flex overflow-x-auto text-sm font-semibold">
<a href="{{ url_for('main.home') }}" class="whitespace-nowrap px-3 py-3 {{ 'text-primary' if active_page == 'home' else 'text-slate-500' }}">主页</a>
<a href="{{ url_for('main.result_page') }}" class="whitespace-nowrap px-3 py-3 {{ 'text-primary' if active_page == 'result' else 'text-slate-500' }}">结果</a>
<a href="{{ url_for('main.history_page') }}" class="whitespace-nowrap px-3 py-3 {{ 'text-primary' if active_page == 'history' else 'text-slate-500' }}">历史</a>
<a href="{{ url_for('main.reference_page') }}" class="whitespace-nowrap px-3 py-3 {{ 'text-primary' if active_page == 'reference' else 'text-slate-500' }}">文档</a>
{% if current_user.is_admin %}
<a href="{{ url_for('main.admin_dashboard') }}" class="whitespace-nowrap px-3 py-3 {{ 'text-primary' if active_page == 'admin' else 'text-slate-500' }}">管理</a>
{% endif %}
</div>
</nav>
</header>
{% with flashed_messages = get_flashed_messages(with_categories=true) %}
<div id="alertBox" class="pointer-events-none fixed left-1/2 top-20 z-50 hidden w-[calc(100vw-2rem)] max-w-[430px] -translate-x-1/2 translate-y-3 rounded-lg border bg-white p-4 opacity-0 shadow-[0_18px_45px_rgba(15,23,42,.16)] transition-all duration-200 ease-out sm:top-24" role="status" aria-live="polite">
<div class="flex items-start gap-3">
<span id="alertIconWrap" class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md">
<span id="alertIcon" class="material-symbols-outlined text-xl">priority_high</span>
</span>
<div class="min-w-0 flex-1 pt-0.5">
<div id="alertTitle" class="text-sm font-extrabold text-textMain"></div>
<div id="alertMessage" class="mt-1 text-sm leading-5 text-textSub"></div>
</div>
<button id="alertClose" class="ui-icon-btn -mr-1 -mt-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700" type="button" aria-label="关闭通知">
<span class="material-symbols-outlined text-base">close</span>
</button>
</div>
</div>
<script>
window.__flashMessages = {{ flashed_messages|tojson }};
</script>
{% endwith %}
<main class="mx-auto w-full max-w-7xl flex-1 px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
{% block content %}{% endblock %}
</main>
<footer class="border-t border-line bg-white">
<div class="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between text-xs text-slate-500">
<span>预测结果仅供参考</span>
<span>© {{ now_year() }} 供水管道健康评估系统</span>
</div>
</footer>
<script>
(() => {
const alertBox = document.getElementById('alertBox');
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');
let alertTimer = null;
let alertHideTimer = null;
if (!alertBox || !alertIconWrap || !alertIcon || !alertTitle || !alertMessage || !alertClose) return;
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 showAppNotification(message, type = 'info', 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('border-red-200');
alertIconWrap.classList.add('bg-dangerSoft', 'text-dangerText');
alertIcon.textContent = 'priority_high';
alertTitle.textContent = title || '操作未完成';
} else {
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);
window.showAppNotification = showAppNotification;
window.hideAppNotification = hideAlert;
const flashedMessages = window.__flashMessages || [];
if (flashedMessages.length) {
const [category, message] = flashedMessages[flashedMessages.length - 1];
showAppNotification(message, category === 'error' ? 'error' : 'info');
}
document.querySelectorAll('[data-clear-home-state]').forEach((form) => {
form.addEventListener('submit', () => {
try {
sessionStorage.removeItem('pipelineLifetime.homeState');
} catch (err) {
// Ignore storage failures during logout.
}
});
});
})();
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+55 -94
View File
@@ -1,99 +1,60 @@
{% extends "base.html" %}
{% from "_pagination.html" import render_pagination %}
<!DOCTYPE html> {% set active_page = "history" %}
<html lang="zh-CN"> {% block title %}预测历史 | 供水管道健康评估系统{% endblock %}
<head>
<title>预测历史</title>
<meta charset="utf-8" /> {% block content %}
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script> <div>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@700;800&display=swap" rel="stylesheet" /> <h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">预测历史</h1>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" /> <p class="mt-2 text-sm text-textSub">查看已上传文件和对应预测报告。</p>
<script> </div>
tailwind.config = { <a href="{{ url_for('main.home') }}" class="ui-btn ui-btn-primary">
darkMode: 'class', <span class="material-symbols-outlined text-lg">upload_file</span>
theme: { 新建分析
extend: { </a>
colors: { </div>
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>
</head> <section class="flex h-[1152px] flex-col rounded-lg border border-line bg-white shadow-panel">
<body class="bg-page min-h-screen p-8 text-textMain"> <div class="border-b border-line px-5 py-4">
<div class="max-w-7xl mx-auto"> <div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center justify-between mb-6"> <h2 class="text-lg font-extrabold">上传记录</h2>
<h1 class="text-3xl font-extrabold">预测历史</h1> {% if pagination.total %}
<a href="{{ url_for('main.home') }}" class="px-4 py-2 rounded-lg border border-slate-200 bg-white">返回主页</a> <span class="text-sm text-textSub">共 {{ pagination.total }} 条</span>
</div> {% endif %}
<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('main.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('main.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>
</div> </div>
</body> <div class="h-[1040px] shrink-0 divide-y divide-line overflow-y-auto">
</html> {% for record in records %}
<div class="flex min-h-[104px] flex-col gap-4 p-5 md:flex-row md:items-center md:justify-between">
<div class="min-w-0">
<div class="truncate font-bold">{{ record.original_filename }}</div>
<div class="mt-1 flex flex-wrap items-center gap-2 text-sm text-textSub">
<span>{{ record.upload_time.strftime('%Y-%m-%d %H:%M:%S') }}</span>
<span class="hidden sm:inline">·</span>
<span>记录 #{{ record.id }}</span>
</div>
</div>
<div class="flex flex-wrap gap-2">
<a class="ui-btn ui-btn-sm ui-btn-secondary" href="{{ url_for('main.download_file', record_id=record.id, file_type='original') }}">
<span class="material-symbols-outlined text-lg">description</span>
原始文件
</a>
<a class="ui-btn ui-btn-sm ui-btn-primary" href="{{ url_for('main.download_file', record_id=record.id, file_type='prediction') }}">
<span class="material-symbols-outlined text-lg">download</span>
预测结果
</a>
</div>
</div>
{% else %}
<div class="flex h-full min-h-[520px] flex-col items-center justify-center p-10 text-center">
<span class="material-symbols-outlined text-5xl text-primary">history</span>
<h2 class="mt-4 text-xl font-extrabold">还没有历史记录</h2>
<p class="mt-2 text-sm text-textSub">上传数据并完成预测后,记录会显示在这里。</p>
</div>
{% endfor %}
</div>
{{ render_pagination(pagination, 'main.history_page') }}
</section>
{% endblock %}
+139 -241
View File
@@ -1,252 +1,150 @@
{% extends "base.html" %}
<!DOCTYPE html> {% set active_page = "home" %}
<html lang="zh-CN"> {% block title %}主页 | 供水管道健康评估系统{% endblock %}
<head>
<title></title>
<meta charset="utf-8" /> {% block content %}
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script> <div>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@700;800&display=swap" rel="stylesheet" /> <h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">管道健康状态与剩余寿命评估</h1>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" /> <p class="mt-2 max-w-3xl text-sm leading-6 text-textSub">上传标准数据文件,系统将生成生存概率曲线、健康等级摘要、剩余寿命判断和电子表格预测报告。</p>
<script> </div>
tailwind.config = { <a href="{{ url_for('main.download_template') }}" class="ui-btn ui-btn-secondary text-primary">
darkMode: 'class', <span class="material-symbols-outlined text-lg">download</span>
theme: { 下载数据模板
extend: { </a>
colors: { </div>
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>
</head> <div id="mainGrid" class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
<body class="bg-page min-h-screen text-textMain"> <section id="uploadPanel" class="order-1 flex h-[460px] flex-col rounded-lg border border-line bg-white p-5 shadow-panel sm:p-6 lg:col-start-1 lg:row-start-1">
<header class="sticky top-0 z-30 bg-white border-b border-line"> <div class="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="px-6 lg:px-10"> <div>
<div class="h-16 flex items-center justify-between gap-4"> <h2 class="text-xl font-extrabold">上传分析文件</h2>
<div class="flex items-center gap-10"> <p class="mt-1 text-sm text-textSub">支持电子表格(.xlsx/.xls)和逗号分隔值文件(.csv),字段名需与模板一致。</p>
<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('main.home') }}" class="flex items-center text-primary border-b-2 border-primary">主页</a>
<a href="{{ url_for('main.result_page') }}" class="flex items-center text-slate-500 hover:text-primary border-b-2 border-transparent">结果</a>
<a href="{{ url_for('main.history_page') }}" class="flex items-center text-slate-500 hover:text-primary border-b-2 border-transparent">历史</a>
{% if current_user.is_admin %}
<a href="{{ url_for('main.admin_dashboard') }}" class="flex items-center text-slate-500 hover:text-primary border-b-2 border-transparent">管理</a>
{% endif %}
</nav>
</div> </div>
<div class="flex items-center gap-4 text-slate-600"> <span class="inline-flex w-fit items-center gap-2 rounded-full bg-blueSoft px-3 py-1 text-xs font-bold text-primary">
<span class="material-symbols-outlined">notifications</span> <span class="material-symbols-outlined text-base">verified</span>
<span class="material-symbols-outlined">help</span> 标准化输入
<div class="h-8 w-px bg-slate-200"></div> </span>
<div class="flex items-center gap-3 text-sm font-semibold"> </div>
<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('main.logout') }}"> <form id="predictForm" action="{{ url_for('main.predict') }}" method="post" enctype="multipart/form-data" novalidate class="flex flex-1 flex-col gap-5">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="text-textMain" type="submit">退出登录</button> <label id="dropZone" class="flex min-h-0 flex-1 cursor-pointer flex-col justify-center rounded-lg border-2 border-dashed border-outline bg-slate-50 px-5 py-8 text-center transition hover:border-primary hover:bg-blue-50/50">
</form> <input id="fileInput" name="file" type="file" accept=".csv,.xls,.xlsx" class="absolute h-px w-px opacity-0">
<span class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-white text-primary shadow-panel">
<span class="material-symbols-outlined text-3xl">cloud_upload</span>
</span>
<span class="mt-4 block text-lg font-extrabold">拖放文件到此处,或点击选择</span>
<span class="mt-1 block text-sm text-textSub">单次上传一个数据文件</span>
<span id="selectedFileName" class="hidden mt-3 text-sm font-bold text-primary"></span>
</label>
<div class="flex shrink-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-xs text-textSub">预测结果会在本页生成摘要,并可进入结果页查看完整报告。</p>
<button id="submitBtn" class="ui-btn ui-btn-primary min-w-[150px]" type="submit">
<span id="submitText">分析并预测</span>
<span id="submitIcon" class="material-symbols-outlined text-lg">analytics</span>
</button>
</div>
</form>
</section>
<section id="inlineResult" class="order-3 h-[520px] overflow-hidden rounded-lg border border-line bg-white p-5 shadow-panel sm:p-6 lg:col-start-1 lg:row-start-2">
<div id="resultPlaceholder" class="flex h-full min-h-0 flex-col">
<div>
<h2 class="text-xl font-extrabold">最新预测结果</h2>
<p class="mt-1 text-sm text-textSub">上传并分析文件后,这里会显示预测图表与报告入口。</p>
</div>
<div class="mt-5 flex min-h-0 flex-1 flex-col items-center justify-center rounded-md border border-dashed border-outline bg-slate-50 px-5 text-center">
<span class="material-symbols-outlined text-4xl text-primary">monitoring</span>
<div class="mt-3 text-sm font-bold text-textMain">暂无预测结果</div>
<div class="mt-1 text-xs leading-5 text-textSub">完成一次分析后,将生成生存概率阶梯图、摘要和电子表格报告。</div>
</div>
</div>
<div id="resultContent" class="hidden h-full min-h-0 flex-col">
<div class="mb-5 flex shrink-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 class="text-xl font-extrabold">最新预测结果</h2>
<p class="mt-1 text-sm text-textSub">已生成图表与电子表格结果文件。</p>
</div>
<div class="flex flex-wrap gap-2">
<a id="resultPageBtn" href="{{ url_for('main.result_page') }}" class="ui-btn ui-btn-sm ui-btn-secondary">
<span class="material-symbols-outlined text-lg">monitoring</span>
查看结果页
</a>
<a id="excelBtn" href="#" class="ui-btn ui-btn-sm ui-btn-primary">
<span class="material-symbols-outlined text-lg">download</span>
下载结果表
</a>
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto pr-1">
<div class="grid gap-4">
<div class="rounded-md border border-line bg-slate-50 p-3">
<div class="mb-2 text-xs font-bold text-textSub">生存概率阶梯图</div>
<img id="resultImage" src="" alt="预测图" class="h-auto w-full rounded bg-white object-contain">
</div>
<div id="resultImportanceWrap" class="hidden rounded-md border border-line bg-slate-50 p-3">
<div class="mb-2 text-xs font-bold text-textSub">输入因素重要性</div>
<img id="resultImportanceImage" src="" alt="重要性排序图" class="h-auto w-full rounded bg-white object-contain">
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </section>
</header>
<main class="px-6 lg:px-10 py-8"> <section id="requirementsPanel" class="order-2 h-[460px] rounded-lg border border-line bg-white p-5 shadow-panel lg:col-start-2 lg:row-start-1">
<section class="mb-8"> <h2 class="flex items-center gap-2 text-lg font-extrabold">
<h1 class="text-[40px] font-extrabold tracking-tight leading-tight">供水管道健康状态与剩余寿命评估技术导则</h1> <span class="material-symbols-outlined text-primary">rule</span>
<p class="mt-2 text-textSub text-[15px]">上传您的数据以生成预测结果供参考。</p> 数据要求
</section> </h2>
<p class="mt-2 text-sm leading-6 text-textSub">为确保准确的生存分析,请确保数据类型严格遵循模板。</p>
{% with messages = get_flashed_messages(with_categories=true) %} <div class="mt-5 space-y-3 text-sm">
{% if messages %} <div class="rounded-md border border-line bg-slate-50 p-3">
<div class="space-y-3 mb-6"> <div class="text-xs font-bold text-primary">必填基础信息</div>
{% for category, message in messages %} <div class="mt-1 font-semibold">管道编号、管龄、状态、管材、管径</div>
<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('main.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" action="{{ url_for('main.predict') }}" method="post" 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('main.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('main.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> </div>
</main> <div class="rounded-md border border-line bg-slate-50 p-3">
<div class="text-xs font-bold text-slate-500">选填历史信息</div>
<div class="mt-1 font-semibold">流速、压力、温度、降雨量、位置</div>
</div>
<div class="rounded-md border border-line bg-slate-50 p-3">
<div class="text-xs font-bold text-slate-500">选填内壁特征</div>
<div class="mt-1 font-semibold">结构缺陷、功能缺陷</div>
</div>
<div class="rounded-md border border-line bg-slate-50 p-3">
<div class="text-xs font-bold text-slate-500">字段格式</div>
<div class="mt-1 font-semibold">字段名需与模板完全一致</div>
</div>
</div>
</section>
<footer class="px-6 lg:px-10 py-6 border-t border-line text-[12px] text-slate-500 flex items-center justify-between"> <section class="order-4 h-[520px] rounded-lg border border-line bg-white p-5 shadow-panel lg:col-start-2 lg:row-start-2">
<span>预测结果仅供参考</span> <h2 class="flex items-center gap-2 text-lg font-extrabold">
<div class="flex gap-6"> <span class="material-symbols-outlined text-primary">quick_reference</span>
<span></span><span></span><span></span><span>文档</span> 快速入口
</div> </h2>
</footer> <div class="mt-4 grid gap-3">
<a href="{{ url_for('main.result_page') }}" class="ui-action-row">
<span class="flex items-center gap-2"><span class="material-symbols-outlined text-lg">monitoring</span>结果页</span>
<span class="material-symbols-outlined text-lg">chevron_right</span>
</a>
<a href="{{ url_for('main.history_page') }}" class="ui-action-row">
<span class="flex items-center gap-2"><span class="material-symbols-outlined text-lg">history</span>预测历史</span>
<span class="material-symbols-outlined text-lg">chevron_right</span>
</a>
<a href="{{ url_for('main.reference_page') }}" class="ui-action-row">
<span class="flex items-center gap-2"><span class="material-symbols-outlined text-lg">picture_as_pdf</span>技术导则</span>
<span class="material-symbols-outlined text-lg">chevron_right</span>
</a>
</div>
</section>
</div>
{% endblock %}
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script> {% block scripts %}
<script src="{{ url_for('static', filename='js/dashboard.js') }}?v=20260706-alert"></script>
</body> {% endblock %}
</html>
+495 -168
View File
@@ -1,85 +1,232 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<title>{{ '注册' if mode == 'register' else '登录' }} | </title> <title>{{ '注册' if mode == 'register' else '登录' }} | 供水管道健康评估系统</title>
<meta charset="utf-8" />
<meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<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=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" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" /> <script>
<script> tailwind.config = {
tailwind.config = { darkMode: 'class',
darkMode: 'class', theme: {
theme: { extend: {
extend: { colors: {
colors: { primary: '#005EB8',
primary: '#005EB8', primaryDeep: '#0c4188',
primaryDeep: '#0c4188', page: '#f3f5f8',
page: '#f3f5f8', card: '#ffffff',
card: '#ffffff', line: '#e5e7eb',
line: '#e5e7eb', textMain: '#0f172a',
textMain: '#0f172a', textSub: '#64748b',
textSub: '#64748b', blueSoft: '#eaf3ff',
blueSoft: '#eaf3ff', bluePanel: '#1d4f9a',
bluePanel: '#1d4f9a', outline: '#c7ced8',
outline: '#c7ced8', successSoft: '#e9f8ee',
successSoft: '#e9f8ee', successText: '#16a34a',
successText: '#16a34a', warnSoft: '#fff4e8',
warnSoft: '#fff4e8', warnText: '#c2410c',
warnText: '#c2410c', dangerSoft: '#fff0f0',
dangerSoft: '#fff0f0', dangerText: '#dc2626',
dangerText: '#dc2626', lowCard: '#f8fafc'
lowCard: '#f8fafc' },
}, fontFamily: {
fontFamily: { headline: ['Manrope', 'Inter', 'sans-serif'],
headline: ['Manrope', 'Inter', 'sans-serif'], body: ['Inter', 'sans-serif']
body: ['Inter', 'sans-serif'] },
}, boxShadow: {
boxShadow: { soft: '0 24px 24px -12px rgba(24,28,30,.06)',
soft: '0 24px 24px -12px rgba(24,28,30,.06)', card: '0 10px 25px rgba(15, 23, 42, .06)'
card: '0 10px 25px rgba(15, 23, 42, .06)' }
} }
} }
} }
} </script>
</script> <style>
<style> .material-symbols-outlined {
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; vertical-align: middle;
vertical-align: middle; }
} .ui-btn {
body { font-family: 'Inter', sans-serif; } box-sizing: border-box;
h1, h2, h3, h4 { font-family: 'Manrope', 'Inter', sans-serif; } display: inline-flex;
.dot-grid { align-items: center;
background-image: radial-gradient(circle at 1px 1px, rgba(148,163,184,.30) 1.2px, transparent 0); justify-content: center;
background-size: 42px 42px; gap: .5rem;
} min-height: 44px;
.panel-frame { height: 44px;
box-shadow: none; border-radius: .375rem;
border: none; border: 1px solid transparent;
} padding: 0 1rem;
.gradient-board { font-size: .875rem;
background: linear-gradient(180deg, #2455a3 0%, #123e7d 100%); font-weight: 700;
} line-height: 1;
.spinner { white-space: nowrap;
width: 18px; height: 18px; border-radius: 9999px; transition: background-color .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease;
border: 2px solid rgba(255,255,255,.35); border-top-color: #fff; }
animation: spin .75s linear infinite; .ui-btn-lg {
} min-height: 52px;
@keyframes spin { to { transform: rotate(360deg); } } height: 52px;
</style> font-size: .875rem;
}
.ui-btn-field {
min-height: 50px;
height: 50px;
}
.ui-btn-primary {
background: #005EB8;
color: #fff;
box-shadow: 0 18px 34px rgba(15, 23, 42, .06);
}
.ui-btn-primary:hover { background: #0c4188; }
.ui-btn-secondary {
border-color: #e2e8f0;
background: #fff;
color: #334155;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
}
.ui-btn-secondary:hover {
border-color: #005EB8;
color: #005EB8;
}
.ui-btn:disabled {
cursor: not-allowed;
box-shadow: none;
opacity: .7;
}
.ui-btn-primary:disabled { background: #cbd5e1; }
.ui-btn .material-symbols-outlined {
display: inline-flex;
width: 20px;
height: 20px;
flex: 0 0 20px;
align-items: center;
justify-content: center;
font-size: 20px;
line-height: 1;
}
.password-toggle {
position: absolute;
right: .75rem;
top: 50%;
display: inline-flex;
width: 32px;
height: 32px;
transform: translateY(-50%);
align-items: center;
justify-content: center;
border: 0;
border-radius: .5rem;
background: transparent;
color: #64748b;
padding: 0;
transition: background-color .15s ease, color .15s ease;
}
.password-toggle:hover {
background: rgba(148, 163, 184, .16);
color: #005EB8;
}
.password-toggle:focus-visible {
outline: 2px solid #005EB8;
outline-offset: 2px;
}
.password-toggle:disabled {
cursor: not-allowed;
opacity: .45;
}
.password-toggle .material-symbols-outlined {
font-size: 20px;
line-height: 1;
}
.auth-input-error {
border-color: #dc2626 !important;
background: #fff7f7 !important;
box-shadow: 0 0 0 3px rgba(220, 38, 38, .12) !important;
}
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;
}
.auth-alert {
pointer-events: none;
opacity: 0;
transform: translateY(.5rem);
}
.auth-alert::before {
content: '';
position: absolute;
left: 24px;
top: -7px;
width: 14px;
height: 14px;
transform: rotate(45deg);
border-left: 1px solid currentColor;
border-top: 1px solid currentColor;
background: #fff;
color: #bfdbfe;
}
.auth-alert.is-error::before {
color: #fecaca;
}
.auth-alert.is-visible {
pointer-events: auto;
opacity: 1;
transform: translateY(0);
}
@media (min-width: 1280px) {
.auth-alert {
transform: translateX(.75rem);
}
.auth-alert::before {
left: -7px;
top: var(--auth-alert-arrow-top, 44px);
border: 0;
border-left: 1px solid currentColor;
border-bottom: 1px solid currentColor;
}
.auth-alert.is-visible {
transform: translateX(0);
}
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head> </head>
<body class="bg-page min-h-screen text-textMain"> <body class="bg-page min-h-screen text-textMain">
{% set page_notice = "当前未开放自助注册。系统仅支持管理员分配账号,请联系管理员完成账号开通后再登录。" if mode == 'register' and not allow_registration else none %}
{% with flashed_messages = get_flashed_messages(with_categories=true) %}
<script>
window.__flashMessages = {{ flashed_messages|tojson }};
window.__pageNotice = {{ page_notice|tojson }};
</script>
<div class="sr-only" aria-hidden="true">
{% for category, message in flashed_messages %}{{ message }}{% endfor %}
{% if page_notice %}{{ page_notice }}{% endif %}
</div>
{% endwith %}
<div class="min-h-screen grid lg:grid-cols-[1.4fr_1fr]"> <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"> <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"> <div></div>
<span class="material-symbols-outlined">water_drop</span>
<span>供水管道健康评估系统</span>
</div>
<div> <div>
<div class="mb-8 flex items-center gap-3 text-[26px] font-extrabold tracking-tight text-white">
<span class="material-symbols-outlined text-[32px]">water_drop</span>
<span>供水管道健康评估系统</span>
</div>
<h1 class="text-[52px] leading-[1.5] font-extrabold tracking-tight"> <h1 class="text-[52px] leading-[1.5] font-extrabold tracking-tight">
供水管道健康状态<br> 供水管道健康状态<br>
与剩余寿命评估<br> 与剩余寿命评估<br>
@@ -90,108 +237,288 @@
<div class="text-white/50 text-[12px]">© {{ now_year() }} 供水管道健康评估系统</div> <div class="text-white/50 text-[12px]">© {{ now_year() }} 供水管道健康评估系统</div>
</section> </section>
<section class="bg-white flex flex-col justify-between min-h-screen"> <section id="authPanel" class="relative 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="flex-1 flex items-center px-7 sm:px-12 md:px-14 py-12">
<div class="w-full max-w-[360px] mx-auto"> <div id="authCard" class="relative w-full max-w-[360px] min-h-[620px] mx-auto">
<h2 class="text-center text-[44px] lg:text-[40px] font-extrabold tracking-tight mb-10">系统门户</h2> <div id="alertBox" class="auth-alert fixed left-7 right-7 top-6 z-50 hidden rounded-lg border bg-white p-3.5 shadow-[0_18px_45px_rgba(15,23,42,.16)] transition-all duration-200 ease-out sm:left-12 sm:right-12 xl:left-auto xl:right-auto xl:w-[320px]" role="status" aria-live="polite">
<div class="flex items-start gap-3">
<div class="flex items-center gap-8 text-[13px] font-semibold border-b border-slate-200 mb-7"> <span id="alertIconWrap" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
<a href="{{ url_for('main.login') }}" class="py-3 {{ 'text-primary border-b-2 border-primary' if mode == 'login' else 'text-slate-500' }}">登录</a> <span id="alertIcon" class="material-symbols-outlined text-lg">priority_high</span>
{% if allow_registration %} </span>
<a href="{{ url_for('main.register') }}" class="py-3 {{ 'text-primary border-b-2 border-primary' if mode == 'register' else 'text-slate-500' }}">注册</a> <div class="min-w-0 flex-1 pt-0.5">
{% endif %} <div id="alertTitle" class="text-sm font-extrabold text-textMain"></div>
<div id="alertMessage" class="mt-1 text-sm leading-5 text-textSub"></div>
</div> </div>
<button id="alertClose" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-slate-400 transition hover:bg-slate-100 hover:text-slate-700" type="button" aria-label="关闭通知">
{% with messages = get_flashed_messages(with_categories=true) %} <span class="material-symbols-outlined text-base">close</span>
{% if messages %} </button>
<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('main.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('main.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('main.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>
</div> </div>
<div class="mb-8 flex items-center justify-center gap-2 text-center text-[18px] font-extrabold text-primary lg:hidden">
<span class="material-symbols-outlined text-[28px]">water_drop</span>
<span>供水管道健康评估系统</span>
</div>
<h2 class="text-center text-[44px] lg:text-[40px] font-extrabold tracking-tight mb-10">系统门户</h2>
<footer class="border-t border-slate-200 bg-[#f8fafc] px-8 py-5"> <div class="flex items-center gap-8 text-[13px] font-semibold border-b border-slate-200 mb-7">
<div class="flex justify-end gap-6 text-[11px] text-slate-500"> <a href="{{ url_for('main.login') }}" class="border-b-2 py-3 {{ 'text-primary border-primary' if mode == 'login' else 'text-slate-500 border-transparent' }}">登录</a>
<span>系统状态</span> <a href="{{ url_for('main.register') }}" class="border-b-2 py-3 {{ 'text-primary border-primary' if mode == 'register' else 'text-slate-500 border-transparent' }}">注册</a>
<span>服务条款</span> </div>
<span>API 文档</span>
{% if mode == 'login' %}
<form method="post" action="{{ url_for('main.login') }}" class="space-y-5" novalidate data-auth-form>
<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 data-field-label="用户名" 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>
</footer>
</section> <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 id="loginPassword" name="password" type="password" required data-field-label="密码" class="w-full pl-11 pr-12 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0" placeholder="请输入密码" />
<button class="password-toggle" type="button" data-password-toggle="loginPassword" aria-label="显示密码" aria-pressed="false">
<span class="material-symbols-outlined" aria-hidden="true">visibility</span>
</button>
</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_44px] 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 data-field-label="验证码" 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('main.login') }}" class="ui-btn ui-btn-field ui-btn-secondary px-0" aria-label="刷新验证码">
<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="ui-btn ui-btn-lg ui-btn-primary w-full mt-2">
登录
<span class="material-symbols-outlined text-lg">arrow_forward</span>
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('main.register') }}" class="space-y-5" novalidate data-auth-form>
<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 data-field-label="用户名" {{ 'disabled' if not allow_registration }} class="w-full pl-11 pr-4 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400" 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 id="registerPassword" name="password" type="password" minlength="6" required data-field-label="密码" {{ 'disabled' if not allow_registration }} class="w-full pl-11 pr-12 py-3.5 rounded-xl bg-[#eceff3] border border-transparent focus:border-primary focus:ring-0 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400" placeholder="请输入至少 6 位密码" />
<button class="password-toggle" type="button" data-password-toggle="registerPassword" aria-label="显示密码" aria-pressed="false" {{ 'disabled' if not allow_registration }}>
<span class="material-symbols-outlined" aria-hidden="true">visibility</span>
</button>
</div>
</div>
<button {{ 'disabled' if not allow_registration }} class="ui-btn ui-btn-lg ui-btn-primary w-full mt-2">
注册
<span class="material-symbols-outlined text-lg">person_add</span>
</button>
</form>
{% endif %}
</div>
</div>
</section>
</div> </div>
<script>
(() => {
const alertBox = document.getElementById('alertBox');
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 authCard = document.getElementById('authCard');
let alertTimer = null;
let alertHideTimer = null;
let alertAnchor = null;
function hideAlert() {
clearTimeout(alertTimer);
clearTimeout(alertHideTimer);
if (!alertBox) return;
alertBox.classList.remove('is-visible');
alertHideTimer = setTimeout(() => {
alertBox.classList.add('hidden');
}, 200);
}
function positionAlert(anchor) {
if (!alertBox || !authCard) return;
const cardRect = authCard.getBoundingClientRect();
const anchorRect = anchor?.getBoundingClientRect();
const useSidePopover = window.matchMedia('(min-width: 1280px)').matches;
if (!useSidePopover) {
alertBox.style.left = '';
alertBox.style.right = '';
alertBox.style.top = '';
alertBox.style.setProperty('--auth-alert-arrow-top', '28px');
return;
}
const width = 320;
const gap = 18;
const viewportPadding = 16;
const desiredLeft = cardRect.right + gap;
const left = Math.min(desiredLeft, window.innerWidth - width - viewportPadding);
const targetCenter = anchorRect ? anchorRect.top + (anchorRect.height / 2) : cardRect.top + 116;
const top = Math.max(viewportPadding, Math.min(targetCenter - 42, window.innerHeight - 140));
const arrowTop = Math.max(22, Math.min(targetCenter - top - 7, 92));
alertBox.style.left = `${left}px`;
alertBox.style.right = 'auto';
alertBox.style.top = `${top}px`;
alertBox.style.setProperty('--auth-alert-arrow-top', `${arrowTop}px`);
}
function showAppNotification(message, type = 'info', title, anchor) {
if (!alertBox || !alertIconWrap || !alertIcon || !alertTitle || !alertMessage) return;
clearTimeout(alertTimer);
clearTimeout(alertHideTimer);
alertAnchor = anchor || null;
positionAlert(anchor);
alertBox.classList.remove('hidden', 'border-red-200', 'border-blue-200', 'is-error');
alertIconWrap.classList.remove('bg-dangerSoft', 'text-dangerText', 'bg-blueSoft', 'text-primary');
if (type === 'error') {
alertBox.classList.add('border-red-200', 'is-error');
alertIconWrap.classList.add('bg-dangerSoft', 'text-dangerText');
alertIcon.textContent = 'priority_high';
alertTitle.textContent = title || '操作未完成';
} else {
alertBox.classList.add('border-blue-200');
alertIconWrap.classList.add('bg-blueSoft', 'text-primary');
alertIcon.textContent = 'info';
alertTitle.textContent = title || '提示';
}
alertMessage.textContent = message;
requestAnimationFrame(() => {
alertBox.classList.add('is-visible');
});
alertTimer = setTimeout(hideAlert, 10000);
}
if (alertClose) {
alertClose.addEventListener('click', hideAlert);
}
window.addEventListener('resize', () => {
if (alertBox && alertBox.classList.contains('is-visible')) {
positionAlert(alertAnchor);
}
});
function markFieldError(field) {
if (!field) return;
field.classList.add('auth-input-error');
field.setAttribute('aria-invalid', 'true');
}
function clearFieldError(field) {
field.classList.remove('auth-input-error');
field.removeAttribute('aria-invalid');
}
function clearFormErrors(form) {
form.querySelectorAll('.auth-input-error').forEach(clearFieldError);
}
function fieldForServerMessage(message) {
const activeForm = document.querySelector('[data-auth-form]');
if (!activeForm) return null;
if (message.includes('验证码')) return activeForm.querySelector('input[name="captcha"]');
if (message.includes('密码')) return activeForm.querySelector('input[name="password"]');
if (message.includes('用户名')) return activeForm.querySelector('input[name="username"]');
return null;
}
const flashedMessages = window.__flashMessages || [];
const pageNotice = window.__pageNotice;
if (flashedMessages.length) {
const [category, message] = flashedMessages[flashedMessages.length - 1];
const serverField = category === 'error' ? fieldForServerMessage(message) : null;
showAppNotification(message, category === 'error' ? 'error' : 'info', undefined, serverField);
if (category === 'error') {
markFieldError(serverField);
}
} else if (pageNotice) {
showAppNotification(pageNotice);
}
document.querySelectorAll('[data-auth-form]').forEach((form) => {
form.querySelectorAll('input').forEach((field) => {
field.addEventListener('input', () => clearFieldError(field));
});
form.addEventListener('submit', (event) => {
clearFormErrors(form);
const fields = Array.from(form.querySelectorAll('input[required]:not(:disabled)'));
const emptyField = fields.find((field) => !field.value.trim());
if (emptyField) {
event.preventDefault();
const label = emptyField.dataset.fieldLabel || '必填项';
showAppNotification(`${label}不能为空`, 'error', undefined, emptyField);
markFieldError(emptyField);
emptyField.focus();
return;
}
const shortPassword = fields.find((field) => {
const minLength = Number(field.getAttribute('minlength'));
return minLength > 0 && field.value.length < minLength;
});
if (shortPassword) {
event.preventDefault();
const minLength = shortPassword.getAttribute('minlength');
showAppNotification(`密码至少需要 ${minLength}`, 'error', undefined, shortPassword);
markFieldError(shortPassword);
shortPassword.focus();
}
});
});
document.querySelectorAll('[data-password-toggle]').forEach((toggle) => {
const input = document.getElementById(toggle.dataset.passwordToggle);
const icon = toggle.querySelector('.material-symbols-outlined');
if (!input || !icon) return;
toggle.addEventListener('click', () => {
const shouldShow = input.type === 'password';
input.type = shouldShow ? 'text' : 'password';
icon.textContent = shouldShow ? 'visibility_off' : 'visibility';
toggle.setAttribute('aria-label', shouldShow ? '隐藏密码' : '显示密码');
toggle.setAttribute('aria-pressed', shouldShow ? 'true' : 'false');
});
});
})();
</script>
</body> </body>
</html> </html>
+36
View File
@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% set active_page = "reference" %}
{% block title %}技术导则 | 供水管道健康评估系统{% endblock %}
{% block content %}
<div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">技术导则参考文档</h1>
<p class="mt-2 text-sm text-textSub">供水管道健康状态与剩余寿命评估技术导则</p>
</div>
<a href="{{ url_for('main.reference_pdf') }}" target="_blank" rel="noopener" class="ui-btn ui-btn-primary">
<span class="material-symbols-outlined text-lg">open_in_new</span>
新窗口查看
</a>
</div>
<section class="overflow-hidden rounded-lg border border-line bg-white shadow-panel">
<div class="border-b border-line px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-2 text-sm font-bold">
<span class="material-symbols-outlined text-primary">picture_as_pdf</span>
文档预览
</div>
<span class="text-xs text-textSub">浏览器不支持内嵌时可使用右上角按钮</span>
</div>
<div class="h-[calc(100vh-230px)] min-h-[560px] bg-slate-100">
<object data="{{ url_for('main.reference_pdf') }}#toolbar=1&navpanes=0" type="application/pdf" class="h-full w-full">
<div class="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<span class="material-symbols-outlined text-5xl text-primary">picture_as_pdf</span>
<p class="text-sm text-textSub">当前浏览器无法内嵌显示参考文档。</p>
<a href="{{ url_for('main.reference_pdf') }}" target="_blank" rel="noopener" class="font-semibold text-primary">打开参考文档</a>
</div>
</object>
</div>
</section>
{% endblock %}
+100 -216
View File
@@ -1,226 +1,110 @@
{% extends "base.html" %}
<!DOCTYPE html> {% set active_page = "result" %}
<html lang="zh-CN"> {% block title %}预测结果 | 供水管道健康评估系统{% endblock %}
<head>
<title>预测结果 </title>
<meta charset="utf-8" /> {% block content %}
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> {% if result %}
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script> <div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@700;800&display=swap" rel="stylesheet" /> <div>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" /> <h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">预测结果</h1>
<script> <p class="mt-2 text-sm text-textSub">文件:<span class="font-semibold text-textMain">{{ result.original_filename }}</span> · 生成于 {{ result.generated_at }}</p>
tailwind.config = { </div>
darkMode: 'class', <div class="flex flex-wrap gap-3">
theme: { <a href="{{ url_for('main.home') }}" class="ui-btn ui-btn-secondary">
extend: { <span class="material-symbols-outlined text-lg">refresh</span>
colors: { 重新分析
primary: '#005EB8', </a>
primaryDeep: '#0c4188', <a href="{{ result.excel_url }}" class="ui-btn ui-btn-primary">
page: '#f3f5f8', <span class="material-symbols-outlined text-lg">download</span>
card: '#ffffff', 导出结果表
line: '#e5e7eb', </a>
textMain: '#0f172a', </div>
textSub: '#64748b', </div>
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>
</head> <div class="grid items-stretch gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
<body class="bg-page min-h-screen text-textMain"> <section class="rounded-lg border border-line bg-white p-5 shadow-panel sm:p-6">
<header class="sticky top-0 z-30 bg-white border-b border-line"> <div class="mb-4">
<div class="px-6 lg:px-10"> <h2 class="text-xl font-extrabold">管道剩余寿命动态评估</h2>
<div class="h-16 flex items-center justify-between gap-4"> <p class="mt-1 text-sm text-textSub">生存概率随时间变化的阶梯曲线。</p>
<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('main.home') }}" class="flex items-center text-slate-500 hover:text-primary border-b-2 border-transparent">主页</a>
<a href="{{ url_for('main.result_page') }}" class="flex items-center text-primary border-b-2 border-primary">结果</a>
<a href="{{ url_for('main.history_page') }}" class="flex items-center text-slate-500 hover:text-primary border-b-2 border-transparent">历史</a>
{% if current_user.is_admin %}
<a href="{{ url_for('main.admin_dashboard') }}" class="flex items-center text-slate-500 hover:text-primary border-b-2 border-transparent">管理</a>
{% endif %}
</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('main.logout') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="text-textMain" type="submit">退出登录</button>
</form>
</div>
</div>
</div>
</div> </div>
</header> <div class="rounded-md border border-line bg-slate-50 p-3">
<img src="{{ result.image_url }}" alt="生存概率阶梯图" class="h-auto w-full rounded bg-white object-contain">
</div>
<div class="mt-4 rounded-md border border-blue-100 bg-blueSoft p-4 text-sm leading-6 text-textSub">
<span class="font-bold text-textMain">分析说明:</span>{{ result.analysis_text }}
</div>
</section>
<main class="px-6 lg:px-10 py-8"> <aside class="flex flex-col gap-6">
{% if result %} <section class="rounded-lg border border-line bg-white p-5 shadow-panel">
<div class="rounded-[14px] bg-blueSoft border border-blue-100 p-4 flex items-center justify-between gap-4 mb-8"> <h2 class="text-lg font-extrabold">报告摘要</h2>
<div class="flex items-center gap-3"> <dl class="mt-4 space-y-3 text-sm">
<div class="w-12 h-12 rounded-xl bg-primary text-white flex items-center justify-center"> <div class="flex justify-between gap-4"><dt class="text-textSub">样本数量</dt><dd class="font-bold">{{ result.sample_count }}</dd></div>
<span class="material-symbols-outlined">description</span> <div class="flex justify-between gap-4"><dt class="text-textSub">显示样本</dt><dd class="font-bold">{{ result.summary_rows|length }}</dd></div>
</div> <div class="flex justify-between gap-4"><dt class="text-textSub">输出文件</dt><dd class="font-bold">电子表格</dd></div>
<div> </dl>
<div class="font-bold">数据分析报告: <span class="text-primary font-mono">{{ result.original_filename }}</span></div> <a href="{{ result.excel_url }}" class="ui-btn ui-btn-secondary mt-5 w-full text-primary">
<div class="text-xs text-textSub mt-1">基于上传数据生成的实时分析报告 • 生成于: {{ result.generated_at }}</div> <span class="material-symbols-outlined text-lg">table_view</span>
</div> 查看完整样本列表
</div> </a>
<div class="flex items-center gap-4 text-xs font-semibold"> </section>
<div class="text-successText flex items-center gap-1"><span class="material-symbols-outlined text-base">check_circle</span>数据源已验证</div>
<a href="{{ url_for('main.home') }}" class="text-primary">返回主页</a> <section class="flex flex-1 flex-col rounded-lg border border-line bg-white p-5 shadow-panel">
</div> <h2 class="text-lg font-extrabold">样本预览</h2>
<div class="mt-4 overflow-hidden rounded-md border border-line">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-xs font-bold text-textSub">
<tr>
<th class="px-3 py-3 text-left">管道编号</th>
<th class="px-3 py-3 text-left">等级</th>
</tr>
</thead>
<tbody>
{% for item in result.summary_rows %}
<tr class="border-t border-line">
<td class="px-3 py-3 font-semibold">{{ item.pipe_id }}</td>
<td class="px-3 py-3"><span class="inline-flex rounded-full px-2.5 py-1 text-xs font-bold {{ item.grade_class }}">{{ item.grade_label }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
</section>
</aside>
<div class="flex flex-col md:flex-row md:items-end justify-between gap-5 mb-6"> {% if result.importance_url %}
<h1 class="text-[64px] leading-none font-extrabold tracking-tight">预测结果</h1> <section class="rounded-lg border border-line bg-white p-5 shadow-panel sm:p-6">
<div class="flex flex-wrap gap-3"> <div class="mb-4">
<a href="{{ url_for('main.home') }}" class="px-6 py-4 rounded-xl border border-slate-200 bg-white font-semibold flex items-center gap-2"> <h2 class="text-xl font-extrabold">模型输入因素重要性排序</h2>
<span class="material-symbols-outlined">refresh</span>重新运行分析 <p class="mt-1 text-sm text-textSub">各输入因素对预测结果的相对影响程度。</p>
</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>
<div class="rounded-md border border-line bg-slate-50 p-3">
<div class="grid lg:grid-cols-[1.08fr_.92fr] gap-6 items-start"> <img src="{{ result.importance_url }}" alt="模型输入因素重要性排序图" class="h-auto w-full rounded bg-white object-contain">
<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> </div>
</section>
{% endif %}
{% if result.importance_url %} <aside class="flex flex-col rounded-lg border border-line bg-white p-5 shadow-panel lg:col-start-2">
<section class="bg-[#fafafa] rounded-[18px] border border-slate-200 p-6 shadow-soft mt-6"> <h2 class="text-lg font-extrabold">健康等级说明</h2>
<div class="flex flex-wrap items-center justify-between gap-3 mb-4"> <div class="mt-4 grid flex-1 gap-2 text-sm">
<div> <div class="rounded-md border border-red-100 bg-dangerSoft p-3"><div class="font-extrabold text-dangerText">I级 · (0, 0.2]</div><p class="mt-1 text-xs leading-5">风险十分严重,需立刻抢修或更新改造。</p></div>
<h2 class="text-[28px] font-extrabold">模型输入因素重要性排序</h2> <div class="rounded-md border border-orange-100 bg-orange-50 p-3"><div class="font-extrabold text-orange-600">II级 · (0.2, 0.4]</div><p class="mt-1 text-xs leading-5">风险较为严重,需尽快检修并加频巡检。</p></div>
<p class="text-sm text-textSub mt-1">各输入因素对预测结果的相对影响程度</p> <div class="rounded-md border border-amber-100 bg-amber-50 p-3"><div class="font-extrabold text-amber-600">III级 · (0.4, 0.6]</div><p class="mt-1 text-xs leading-5">风险较低,安排定期巡检。</p></div>
</div> <div class="rounded-md border border-blue-100 bg-blue-50 p-3"><div class="font-extrabold text-blue-600">IV级 · (0.6, 0.8]</div><p class="mt-1 text-xs leading-5">风险较小,维持常规巡视。</p></div>
</div> <div class="rounded-md border border-blue-100 bg-blueSoft p-3"><div class="font-extrabold text-primary">V级 · (0.8, 1]</div><p class="mt-1 text-xs leading-5">管道安全,维持常规巡视。</p></div>
<div class="bg-white rounded-xl border border-slate-200 p-4"> </div>
<img src="{{ result.importance_url }}" alt="模型输入因素重要性排序图" class="w-full h-auto object-contain"> </aside>
</div> </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"> {% else %}
<span class="material-symbols-outlined text-primary mt-0.5">insights</span> <section class="rounded-lg border border-line bg-white p-10 text-center shadow-panel">
<p><span class="font-bold text-textMain">说明:</span>柱状图按重要性从高到低展示各输入因素对模型预测结果的相对贡献,数值为归一化占比,可用于辅助识别影响管道健康状态的关键因素。</p> <span class="material-symbols-outlined text-5xl text-primary">monitoring</span>
</div> <h1 class="mt-4 text-2xl font-extrabold">当前还没有预测结果</h1>
</section> <p class="mt-2 text-sm text-textSub">请先从主页上传文件并运行预测。</p>
{% endif %} <a href="{{ url_for('main.home') }}" class="ui-btn ui-btn-primary mt-6">
{% else %} <span class="material-symbols-outlined text-lg">upload_file</span>
<div class="rounded-2xl bg-white border border-slate-200 p-10 text-center text-textSub">当前还没有预测结果,请先从主页上传文件并运行预测。</div> 去上传数据
{% endif %} </a>
</main> </section>
{% endif %}
<footer class="px-6 lg:px-10 py-6 border-t border-line text-[12px] text-slate-500 flex items-center justify-between"> {% endblock %}
<span>预测结果仅供参考</span>
<div class="flex gap-6"><span></span><span></span><span></span><span></span></div>
</footer>
</body>
</html>
+207
View File
@@ -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("第 <span class=\"font-bold text-textMain\">2</span> / 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()
+32
View File
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
import pandas as pd import pandas as pd
@@ -13,9 +15,16 @@ from app.prediction import (
interpolate_probability, interpolate_probability,
secure_upload_name, secure_upload_name,
validate_input_frame, 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): class PredictionHelpersTest(unittest.TestCase):
def test_secure_upload_name_accepts_chinese_filename(self) -> None: def test_secure_upload_name_accepts_chinese_filename(self) -> None:
filename, suffix = secure_upload_name("管道数据.xlsx", "run123") filename, suffix = secure_upload_name("管道数据.xlsx", "run123")
@@ -44,6 +53,29 @@ class PredictionHelpersTest(unittest.TestCase):
self.assertIn("缺少必要字段", ctx.exception.message) 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__": if __name__ == "__main__":
unittest.main() unittest.main()