feat: update assessment portal
This commit is contained in:
Binary file not shown.
+6
-3
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+71
-14
@@ -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]] = []
|
||||||
pd.DataFrame(summary_sheet_rows).to_excel(writer, sheet_name="结果摘要", index=False)
|
|
||||||
for i, curve in enumerate(curves):
|
for i, curve in enumerate(curves):
|
||||||
times = [float(x) for x in list(curve.x)]
|
times = [float(x) for x in list(curve.x)]
|
||||||
probs = [float(y) for y in list(curve.y)]
|
probs = [float(y) for y in list(curve.y)]
|
||||||
pipe_id = summary_rows[i]["pipe_id"]
|
pipe_id = summary_rows[i]["pipe_id"]
|
||||||
out_df = pd.DataFrame({"时间(年)": times, f"{pipe_id}生存概率": probs})
|
for time, probability in zip(times, probs):
|
||||||
out_df.to_excel(writer, sheet_name=f"样本{i+1}", index=False)
|
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(sample_data_rows).to_excel(writer, sheet_name="样本数据", index=False)
|
||||||
|
|||||||
+92
-10
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+141
-29
@@ -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,
|
||||||
|
result_url: data.result_url
|
||||||
}
|
}
|
||||||
excelBtn.href = data.excel_url;
|
});
|
||||||
resultPageBtn.href = data.result_url;
|
showAlert('预测完成,已生成图表与电子表格报告。', 'success');
|
||||||
summaryFilename.textContent = data.original_filename;
|
|
||||||
summaryCount.textContent = data.sample_count;
|
|
||||||
inlineResult.classList.remove('hidden');
|
|
||||||
mainGrid.classList.remove('xl:grid-cols-[1.45fr_1fr]');
|
|
||||||
mainGrid.classList.add('xl:grid-cols-[1.25fr_0.95fr_1.05fr]');
|
|
||||||
showAlert('预测完成,已生成图表与 Excel 报告。', 'success');
|
|
||||||
inlineResult.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
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();
|
||||||
|
|||||||
@@ -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 %}
|
||||||
+115
-96
@@ -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>
|
|
||||||
tailwind.config = {
|
|
||||||
darkMode: 'class',
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
primary: '#005EB8',
|
|
||||||
primaryDeep: '#0c4188',
|
|
||||||
page: '#f3f5f8',
|
|
||||||
card: '#ffffff',
|
|
||||||
line: '#e5e7eb',
|
|
||||||
textMain: '#0f172a',
|
|
||||||
textSub: '#64748b',
|
|
||||||
blueSoft: '#eaf3ff',
|
|
||||||
bluePanel: '#1d4f9a',
|
|
||||||
outline: '#c7ced8',
|
|
||||||
successSoft: '#e9f8ee',
|
|
||||||
successText: '#16a34a',
|
|
||||||
warnSoft: '#fff4e8',
|
|
||||||
warnText: '#c2410c',
|
|
||||||
dangerSoft: '#fff0f0',
|
|
||||||
dangerText: '#dc2626',
|
|
||||||
lowCard: '#f8fafc'
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
headline: ['Manrope', 'Inter', 'sans-serif'],
|
|
||||||
body: ['Inter', 'sans-serif']
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
soft: '0 24px 24px -12px rgba(24,28,30,.06)',
|
|
||||||
card: '0 10px 25px rgba(15, 23, 42, .06)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
.material-symbols-outlined {
|
|
||||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
body { font-family: 'Inter', sans-serif; }
|
|
||||||
h1, h2, h3, h4 { font-family: 'Manrope', 'Inter', sans-serif; }
|
|
||||||
.dot-grid {
|
|
||||||
background-image: radial-gradient(circle at 1px 1px, rgba(148,163,184,.30) 1.2px, transparent 0);
|
|
||||||
background-size: 42px 42px;
|
|
||||||
}
|
|
||||||
.panel-frame {
|
|
||||||
box-shadow: none;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
.gradient-board {
|
|
||||||
background: linear-gradient(180deg, #2455a3 0%, #123e7d 100%);
|
|
||||||
}
|
|
||||||
.spinner {
|
|
||||||
width: 18px; height: 18px; border-radius: 9999px;
|
|
||||||
border: 2px solid rgba(255,255,255,.35); border-top-color: #fff;
|
|
||||||
animation: spin .75s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body class="bg-page min-h-screen p-8 text-textMain">
|
|
||||||
<div class="max-w-7xl mx-auto">
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h1 class="text-3xl font-extrabold">管理员查看上传记录</h1>
|
|
||||||
<a href="{{ url_for('main.home') }}" class="px-4 py-2 rounded-lg border border-slate-200 bg-white">返回主页</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white rounded-2xl border border-slate-200 overflow-hidden shadow-card">
|
<a href="{{ url_for('main.home') }}" class="ui-btn ui-btn-secondary">
|
||||||
<table class="w-full text-sm">
|
<span class="material-symbols-outlined text-lg">arrow_back</span>
|
||||||
<thead class="bg-slate-50 text-slate-500">
|
返回主页
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="mb-6 rounded-lg border border-line bg-white p-5 shadow-panel">
|
||||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-extrabold tracking-tight">用户注册</h2>
|
||||||
|
<p class="mt-1 text-sm text-textSub">
|
||||||
|
当前状态:<span id="registrationStatus" class="font-bold {{ 'text-successText' if registration_allowed else 'text-slate-600' }}">{{ '已开放' if registration_allowed else '已关闭' }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form id="registrationForm" method="post" action="{{ url_for('main.update_registration_setting') }}" class="flex items-center gap-3">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<label class="inline-flex items-center gap-2 text-sm font-semibold text-slate-600">
|
||||||
|
<input id="registrationToggle" type="checkbox" name="allow_registration" class="rounded border-slate-300 text-primary focus:ring-primary" {{ 'checked' if registration_allowed }}>
|
||||||
|
允许自助注册
|
||||||
|
</label>
|
||||||
|
<button id="registrationSubmit" type="submit" class="ui-btn ui-btn-sm ui-btn-primary">
|
||||||
|
<span class="material-symbols-outlined text-lg">save</span>
|
||||||
|
<span id="registrationSubmitText">保存</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="flex min-h-[760px] flex-col overflow-hidden rounded-lg border border-line bg-white shadow-panel">
|
||||||
|
<div class="border-b border-line px-5 py-4">
|
||||||
|
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<h2 class="text-lg font-extrabold">上传记录</h2>
|
||||||
|
{% if pagination.total %}
|
||||||
|
<span class="text-sm text-textSub">共 {{ pagination.total }} 条</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-x-auto">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead class="bg-slate-50 text-xs font-bold uppercase tracking-[0.12em] text-textSub">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-left px-4 py-3">用户</th>
|
<th class="px-5 py-4 text-left">用户</th>
|
||||||
<th class="text-left px-4 py-3">原始文件</th>
|
<th class="px-5 py-4 text-left">原始文件</th>
|
||||||
<th class="text-left px-4 py-3">上传时间</th>
|
<th class="px-5 py-4 text-left">上传时间</th>
|
||||||
<th class="text-left px-4 py-3">下载</th>
|
<th class="px-5 py-4 text-left">下载</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody class="divide-y divide-line">
|
||||||
{% for record in records %}
|
{% for record in records %}
|
||||||
<tr class="border-t border-slate-100">
|
<tr class="hover:bg-slate-50">
|
||||||
<td class="px-4 py-3">{{ record.user.username }}</td>
|
<td class="px-5 py-4 font-semibold">{{ record.user.username }}</td>
|
||||||
<td class="px-4 py-3">{{ record.original_filename }}</td>
|
<td class="max-w-[420px] truncate px-5 py-4">{{ record.original_filename }}</td>
|
||||||
<td class="px-4 py-3">{{ record.upload_time.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
<td class="px-5 py-4 text-textSub">{{ record.upload_time.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||||
<td class="px-4 py-3 flex gap-3">
|
<td class="px-5 py-4">
|
||||||
<a class="text-primary" href="{{ url_for('main.download_file', record_id=record.id, file_type='original') }}">原始文件</a>
|
<div class="flex flex-wrap gap-3">
|
||||||
<a class="text-primary" href="{{ url_for('main.download_file', record_id=record.id, file_type='prediction') }}">预测结果</a>
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="4" class="px-4 py-8 text-center text-slate-500">暂无上传记录</td></tr>
|
<tr>
|
||||||
|
<td colspan="4" class="px-5 py-12 text-center text-textSub">暂无上传记录</td>
|
||||||
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{{ render_pagination(pagination, 'main.admin_dashboard') }}
|
||||||
</body>
|
</section>
|
||||||
</html>
|
{% 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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
+51
-90
@@ -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>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@700;800&display=swap" rel="stylesheet" />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
|
|
||||||
<script>
|
|
||||||
tailwind.config = {
|
|
||||||
darkMode: 'class',
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
primary: '#005EB8',
|
|
||||||
primaryDeep: '#0c4188',
|
|
||||||
page: '#f3f5f8',
|
|
||||||
card: '#ffffff',
|
|
||||||
line: '#e5e7eb',
|
|
||||||
textMain: '#0f172a',
|
|
||||||
textSub: '#64748b',
|
|
||||||
blueSoft: '#eaf3ff',
|
|
||||||
bluePanel: '#1d4f9a',
|
|
||||||
outline: '#c7ced8',
|
|
||||||
successSoft: '#e9f8ee',
|
|
||||||
successText: '#16a34a',
|
|
||||||
warnSoft: '#fff4e8',
|
|
||||||
warnText: '#c2410c',
|
|
||||||
dangerSoft: '#fff0f0',
|
|
||||||
dangerText: '#dc2626',
|
|
||||||
lowCard: '#f8fafc'
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
headline: ['Manrope', 'Inter', 'sans-serif'],
|
|
||||||
body: ['Inter', 'sans-serif']
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
soft: '0 24px 24px -12px rgba(24,28,30,.06)',
|
|
||||||
card: '0 10px 25px rgba(15, 23, 42, .06)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
.material-symbols-outlined {
|
|
||||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
body { font-family: 'Inter', sans-serif; }
|
|
||||||
h1, h2, h3, h4 { font-family: 'Manrope', 'Inter', sans-serif; }
|
|
||||||
.dot-grid {
|
|
||||||
background-image: radial-gradient(circle at 1px 1px, rgba(148,163,184,.30) 1.2px, transparent 0);
|
|
||||||
background-size: 42px 42px;
|
|
||||||
}
|
|
||||||
.panel-frame {
|
|
||||||
box-shadow: none;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
.gradient-board {
|
|
||||||
background: linear-gradient(180deg, #2455a3 0%, #123e7d 100%);
|
|
||||||
}
|
|
||||||
.spinner {
|
|
||||||
width: 18px; height: 18px; border-radius: 9999px;
|
|
||||||
border: 2px solid rgba(255,255,255,.35); border-top-color: #fff;
|
|
||||||
animation: spin .75s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body class="bg-page min-h-screen p-8 text-textMain">
|
|
||||||
<div class="max-w-7xl mx-auto">
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h1 class="text-3xl font-extrabold">预测历史</h1>
|
|
||||||
<a href="{{ url_for('main.home') }}" class="px-4 py-2 rounded-lg border border-slate-200 bg-white">返回主页</a>
|
|
||||||
</div>
|
|
||||||
<div class="grid gap-4">
|
|
||||||
{% for record in records %}
|
|
||||||
<div class="bg-white rounded-2xl border border-slate-200 p-5 shadow-card flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<div class="font-bold">{{ record.original_filename }}</div>
|
<h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">预测历史</h1>
|
||||||
<div class="text-sm text-textSub mt-1">{{ record.upload_time.strftime('%Y-%m-%d %H:%M:%S') }}</div>
|
<p class="mt-2 text-sm text-textSub">查看已上传文件和对应预测报告。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-3">
|
<a href="{{ url_for('main.home') }}" class="ui-btn ui-btn-primary">
|
||||||
<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>
|
<span class="material-symbols-outlined text-lg">upload_file</span>
|
||||||
<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>
|
新建分析
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="flex h-[1152px] flex-col rounded-lg border border-line bg-white shadow-panel">
|
||||||
|
<div class="border-b border-line px-5 py-4">
|
||||||
|
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<h2 class="text-lg font-extrabold">上传记录</h2>
|
||||||
|
{% if pagination.total %}
|
||||||
|
<span class="text-sm text-textSub">共 {{ pagination.total }} 条</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="h-[1040px] shrink-0 divide-y divide-line overflow-y-auto">
|
||||||
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="bg-white rounded-2xl border border-slate-200 p-10 text-center text-textSub">还没有历史记录。</div>
|
<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 %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{{ render_pagination(pagination, 'main.history_page') }}
|
||||||
</body>
|
</section>
|
||||||
</html>
|
{% endblock %}
|
||||||
|
|||||||
+115
-217
@@ -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>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@700;800&display=swap" rel="stylesheet" />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
|
|
||||||
<script>
|
|
||||||
tailwind.config = {
|
|
||||||
darkMode: 'class',
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
primary: '#005EB8',
|
|
||||||
primaryDeep: '#0c4188',
|
|
||||||
page: '#f3f5f8',
|
|
||||||
card: '#ffffff',
|
|
||||||
line: '#e5e7eb',
|
|
||||||
textMain: '#0f172a',
|
|
||||||
textSub: '#64748b',
|
|
||||||
blueSoft: '#eaf3ff',
|
|
||||||
bluePanel: '#1d4f9a',
|
|
||||||
outline: '#c7ced8',
|
|
||||||
successSoft: '#e9f8ee',
|
|
||||||
successText: '#16a34a',
|
|
||||||
warnSoft: '#fff4e8',
|
|
||||||
warnText: '#c2410c',
|
|
||||||
dangerSoft: '#fff0f0',
|
|
||||||
dangerText: '#dc2626',
|
|
||||||
lowCard: '#f8fafc'
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
headline: ['Manrope', 'Inter', 'sans-serif'],
|
|
||||||
body: ['Inter', 'sans-serif']
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
soft: '0 24px 24px -12px rgba(24,28,30,.06)',
|
|
||||||
card: '0 10px 25px rgba(15, 23, 42, .06)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
.material-symbols-outlined {
|
|
||||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
body { font-family: 'Inter', sans-serif; }
|
|
||||||
h1, h2, h3, h4 { font-family: 'Manrope', 'Inter', sans-serif; }
|
|
||||||
.dot-grid {
|
|
||||||
background-image: radial-gradient(circle at 1px 1px, rgba(148,163,184,.30) 1.2px, transparent 0);
|
|
||||||
background-size: 42px 42px;
|
|
||||||
}
|
|
||||||
.panel-frame {
|
|
||||||
box-shadow: none;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
.gradient-board {
|
|
||||||
background: linear-gradient(180deg, #2455a3 0%, #123e7d 100%);
|
|
||||||
}
|
|
||||||
.spinner {
|
|
||||||
width: 18px; height: 18px; border-radius: 9999px;
|
|
||||||
border: 2px solid rgba(255,255,255,.35); border-top-color: #fff;
|
|
||||||
animation: spin .75s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body class="bg-page min-h-screen text-textMain">
|
|
||||||
<header class="sticky top-0 z-30 bg-white border-b border-line">
|
|
||||||
<div class="px-6 lg:px-10">
|
|
||||||
<div class="h-16 flex items-center justify-between gap-4">
|
|
||||||
<div class="flex items-center gap-10">
|
|
||||||
<div class="flex items-center gap-2 text-[18px] font-extrabold tracking-tight">
|
|
||||||
<span class="material-symbols-outlined text-primary">water_drop</span>
|
|
||||||
<span>供水管道健康评估系统</span>
|
|
||||||
</div>
|
|
||||||
<nav class="hidden md:flex items-center gap-7 text-[13px] font-semibold self-stretch">
|
|
||||||
<a href="{{ url_for('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 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>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="px-6 lg:px-10 py-8">
|
|
||||||
<section class="mb-8">
|
|
||||||
<h1 class="text-[40px] font-extrabold tracking-tight leading-tight">供水管道健康状态与剩余寿命评估技术导则</h1>
|
|
||||||
<p class="mt-2 text-textSub text-[15px]">上传您的数据以生成预测结果供参考。</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
||||||
{% if messages %}
|
|
||||||
<div class="space-y-3 mb-6">
|
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="rounded-xl px-4 py-3 text-sm border {{ 'bg-dangerSoft text-dangerText border-red-200' if category == 'error' else 'bg-blueSoft text-primary border-blue-200' }}">{{ message }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
<div id="alertBox" class="hidden mb-6 rounded-xl px-4 py-3 text-sm border"></div>
|
|
||||||
|
|
||||||
<div id="mainGrid" class="grid grid-cols-1 xl:grid-cols-[1.45fr_1fr] gap-6 items-start">
|
|
||||||
<div>
|
<div>
|
||||||
<section class="bg-[#fafafa] rounded-[18px] border border-slate-200 p-7 shadow-soft">
|
<h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">管道健康状态与剩余寿命评估</h1>
|
||||||
<div class="flex items-center justify-between gap-4 mb-5">
|
<p class="mt-2 max-w-3xl text-sm leading-6 text-textSub">上传标准数据文件,系统将生成生存概率曲线、健康等级摘要、剩余寿命判断和电子表格预测报告。</p>
|
||||||
<h2 class="text-[22px] font-bold flex items-center gap-2">
|
</div>
|
||||||
<span class="material-symbols-outlined text-primary">upload_file</span>
|
<a href="{{ url_for('main.download_template') }}" class="ui-btn ui-btn-secondary text-primary">
|
||||||
文件上传
|
<span class="material-symbols-outlined text-lg">download</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>
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="mainGrid" class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||||
|
<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">
|
||||||
|
<div class="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-extrabold">上传分析文件</h2>
|
||||||
|
<p class="mt-1 text-sm text-textSub">支持电子表格(.xlsx/.xls)和逗号分隔值文件(.csv),字段名需与模板一致。</p>
|
||||||
|
</div>
|
||||||
|
<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 text-base">verified</span>
|
||||||
|
标准化输入
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="predictForm" action="{{ url_for('main.predict') }}" method="post" enctype="multipart/form-data">
|
<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() }}">
|
||||||
<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">
|
<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">
|
||||||
<input id="fileInput" name="file" type="file" accept=".csv,.xls,.xlsx" class="absolute inset-0 opacity-0 cursor-pointer" required>
|
<input id="fileInput" name="file" type="file" accept=".csv,.xls,.xlsx" class="absolute h-px w-px opacity-0">
|
||||||
<div class="flex flex-col items-center gap-4 pointer-events-none">
|
<span class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-white text-primary shadow-panel">
|
||||||
<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>
|
<span class="material-symbols-outlined text-3xl">cloud_upload</span>
|
||||||
</div>
|
</span>
|
||||||
<div>
|
<span class="mt-4 block text-lg font-extrabold">拖放文件到此处,或点击选择</span>
|
||||||
<p class="text-[28px] font-bold">将您的数据文件拖放到此处</p>
|
<span class="mt-1 block text-sm text-textSub">单次上传一个数据文件</span>
|
||||||
<p class="text-[15px] text-textSub mt-1">支持 Excel (.xlsx) 和 CSV 文件</p>
|
<span id="selectedFileName" class="hidden mt-3 text-sm font-bold text-primary"></span>
|
||||||
<p id="selectedFileName" class="hidden mt-3 text-sm text-primary font-semibold"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="mt-8 flex justify-end">
|
<div class="flex shrink-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<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">
|
<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="submitText">分析并预测</span>
|
||||||
<span id="submitIcon" class="material-symbols-outlined">analytics</span>
|
<span id="submitIcon" class="material-symbols-outlined text-lg">analytics</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="mt-5">
|
<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">
|
||||||
<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 id="resultPlaceholder" class="flex h-full min-h-0 flex-col">
|
||||||
<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>
|
<div>
|
||||||
<h3 class="font-bold text-[18px]">进入结果页</h3>
|
<h2 class="text-xl font-extrabold">最新预测结果</h2>
|
||||||
<p class="text-sm text-textSub">查看完整的健康评估与重要性分析</p>
|
<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>
|
</div>
|
||||||
<span class="material-symbols-outlined text-slate-400">chevron_right</span>
|
|
||||||
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside>
|
<div class="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||||
<section class="bg-[#fafafa] rounded-[18px] border border-slate-200 p-7 shadow-soft">
|
<div class="grid gap-4">
|
||||||
<h2 class="text-[22px] font-bold flex items-center gap-2 mb-4">
|
<div class="rounded-md border border-line bg-slate-50 p-3">
|
||||||
<span class="material-symbols-outlined text-[#b45309]">info</span>
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<h2 class="flex items-center gap-2 text-lg font-extrabold">
|
||||||
|
<span class="material-symbols-outlined text-primary">rule</span>
|
||||||
数据要求
|
数据要求
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-[14px] text-textSub leading-7 mb-6">为确保准确的生存分析,您上传的文件必须包含以下字段。请确保数据类型严格遵循模板。</p>
|
<p class="mt-2 text-sm leading-6 text-textSub">为确保准确的生存分析,请确保数据类型严格遵循模板。</p>
|
||||||
<div class="space-y-3">
|
<div class="mt-5 space-y-3 text-sm">
|
||||||
<div class="bg-white rounded-xl border-l-4 border-primary p-4 border border-slate-200">
|
<div class="rounded-md border border-line bg-slate-50 p-3">
|
||||||
<div class="text-[11px] font-extrabold uppercase tracking-[0.18em] text-primary mb-1">必填基础信息</div>
|
<div class="text-xs font-bold text-primary">必填基础信息</div>
|
||||||
<div class="font-semibold">管道编号、管龄、状态、管材、管径</div>
|
<div class="mt-1 font-semibold">管道编号、管龄、状态、管材、管径</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white rounded-xl border-l-4 border-slate-500 p-4 border border-slate-200">
|
<div class="rounded-md border border-line bg-slate-50 p-3">
|
||||||
<div class="text-[11px] font-extrabold uppercase tracking-[0.18em] text-slate-500 mb-1">选填历史信息</div>
|
<div class="text-xs font-bold text-slate-500">选填历史信息</div>
|
||||||
<div class="font-semibold">流速、压力、温度、降雨量、位置</div>
|
<div class="mt-1 font-semibold">流速、压力、温度、降雨量、位置</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white rounded-xl border-l-4 border-[#ea580c] p-4 border border-slate-200">
|
<div class="rounded-md border border-line bg-slate-50 p-3">
|
||||||
<div class="text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#ea580c] mb-1">选填内壁特征</div>
|
<div class="text-xs font-bold text-slate-500">选填内壁特征</div>
|
||||||
<div class="font-semibold">结构缺陷、功能缺陷</div>
|
<div class="mt-1 font-semibold">结构缺陷、功能缺陷</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white rounded-xl border-l-4 border-red-500 p-4 border border-slate-200">
|
<div class="rounded-md border border-line bg-slate-50 p-3">
|
||||||
<div class="text-[11px] font-extrabold uppercase tracking-[0.18em] text-red-500 mb-1">选填运行环境</div>
|
<div class="text-xs font-bold text-slate-500">字段格式</div>
|
||||||
<div class="font-semibold">字段名需与模板完全一致</div>
|
<div class="mt-1 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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section id="inlineResult" class="hidden bg-[#fafafa] rounded-[18px] border border-slate-200 p-6 shadow-soft self-stretch">
|
<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">
|
||||||
<div class="mb-4">
|
<h2 class="flex items-center gap-2 text-lg font-extrabold">
|
||||||
<h2 class="text-[24px] font-extrabold">最新预测结果</h2>
|
<span class="material-symbols-outlined text-primary">quick_reference</span>
|
||||||
<p class="text-textSub text-sm mt-1">已生成预测图和 Excel 结果文件。</p>
|
快速入口
|
||||||
</div>
|
</h2>
|
||||||
<div class="bg-white rounded-xl border border-slate-200 p-4 mb-4">
|
<div class="mt-4 grid gap-3">
|
||||||
<div class="text-xs font-semibold text-slate-500 mb-2">生存概率阶梯图</div>
|
<a href="{{ url_for('main.result_page') }}" class="ui-action-row">
|
||||||
<img id="resultImage" src="" alt="预测图" class="w-full h-auto object-contain">
|
<span class="flex items-center gap-2"><span class="material-symbols-outlined text-lg">monitoring</span>结果页</span>
|
||||||
</div>
|
<span class="material-symbols-outlined text-lg">chevron_right</span>
|
||||||
<div id="resultImportanceWrap" class="hidden bg-white rounded-xl border border-slate-200 p-4 mb-4">
|
</a>
|
||||||
<div class="text-xs font-semibold text-slate-500 mb-2">模型输入因素重要性排序</div>
|
<a href="{{ url_for('main.history_page') }}" class="ui-action-row">
|
||||||
<img id="resultImportanceImage" src="" alt="重要性排序图" class="w-full h-auto object-contain">
|
<span class="flex items-center gap-2"><span class="material-symbols-outlined text-lg">history</span>预测历史</span>
|
||||||
</div>
|
<span class="material-symbols-outlined text-lg">chevron_right</span>
|
||||||
<div class="bg-white rounded-xl border border-slate-200 p-5 mb-4">
|
</a>
|
||||||
<div class="text-xs uppercase tracking-[0.18em] text-slate-500 mb-3">结果摘要</div>
|
<a href="{{ url_for('main.reference_page') }}" class="ui-action-row">
|
||||||
<div class="space-y-3 text-sm">
|
<span class="flex items-center gap-2"><span class="material-symbols-outlined text-lg">picture_as_pdf</span>技术导则</span>
|
||||||
<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>
|
<span class="material-symbols-outlined text-lg">chevron_right</span>
|
||||||
<div class="flex items-center justify-between gap-4"><span class="text-textSub">预测样本数</span><span id="summaryCount" class="font-semibold">-</span></div>
|
</a>
|
||||||
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
{% endblock %}
|
||||||
|
|
||||||
<footer class="px-6 lg:px-10 py-6 border-t border-line text-[12px] text-slate-500 flex items-center justify-between">
|
{% block scripts %}
|
||||||
<span>预测结果仅供参考</span>
|
<script src="{{ url_for('static', filename='js/dashboard.js') }}?v=20260706-alert"></script>
|
||||||
<div class="flex gap-6">
|
{% endblock %}
|
||||||
<span></span><span></span><span></span><span>文档</span>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
+380
-53
@@ -1,15 +1,13 @@
|
|||||||
|
|
||||||
<!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: {
|
||||||
@@ -44,12 +42,108 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</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 {
|
||||||
|
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-lg {
|
||||||
|
min-height: 52px;
|
||||||
|
height: 52px;
|
||||||
|
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; }
|
body { font-family: 'Inter', sans-serif; }
|
||||||
h1, h2, h3, h4 { font-family: 'Manrope', 'Inter', sans-serif; }
|
h1, h2, h3, h4 { font-family: 'Manrope', 'Inter', sans-serif; }
|
||||||
.dot-grid {
|
.dot-grid {
|
||||||
@@ -68,18 +162,71 @@
|
|||||||
border: 2px solid rgba(255,255,255,.35); border-top-color: #fff;
|
border: 2px solid rgba(255,255,255,.35); border-top-color: #fff;
|
||||||
animation: spin .75s linear infinite;
|
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); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
</style>
|
</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>
|
<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>
|
<span>供水管道健康评估系统</span>
|
||||||
</div>
|
</div>
|
||||||
<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,59 +237,68 @@
|
|||||||
<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">
|
||||||
|
<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">
|
||||||
|
<span id="alertIconWrap" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
|
||||||
|
<span id="alertIcon" class="material-symbols-outlined text-lg">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="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="关闭通知">
|
||||||
|
<span class="material-symbols-outlined text-base">close</span>
|
||||||
|
</button>
|
||||||
|
</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>
|
<h2 class="text-center text-[44px] lg:text-[40px] font-extrabold tracking-tight mb-10">系统门户</h2>
|
||||||
|
|
||||||
<div class="flex items-center gap-8 text-[13px] font-semibold border-b border-slate-200 mb-7">
|
<div class="flex items-center gap-8 text-[13px] font-semibold border-b border-slate-200 mb-7">
|
||||||
<a href="{{ url_for('main.login') }}" class="py-3 {{ 'text-primary border-b-2 border-primary' if mode == 'login' else 'text-slate-500' }}">登录</a>
|
<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>
|
||||||
{% if allow_registration %}
|
<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>
|
||||||
<a href="{{ url_for('main.register') }}" class="py-3 {{ 'text-primary border-b-2 border-primary' if mode == 'register' else 'text-slate-500' }}">注册</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
||||||
{% if messages %}
|
|
||||||
<div class="space-y-3 mb-5">
|
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="rounded-xl px-4 py-3 text-sm border {{ 'bg-dangerSoft text-dangerText border-red-200' if category == 'error' else 'bg-blueSoft text-primary border-blue-200' }}">{{ message }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
{% if mode == 'login' %}
|
{% if mode == 'login' %}
|
||||||
<form method="post" action="{{ url_for('main.login') }}" class="space-y-5">
|
<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() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">身份识别 / 用户名</label>
|
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">用户名</label>
|
||||||
<div class="relative">
|
<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>
|
<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" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500">安全凭据</label>
|
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500">密码</label>
|
||||||
<span class="text-[12px] text-primary font-semibold">找回密码</span>
|
<span class="text-[12px] text-primary font-semibold">找回密码</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative">
|
<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>
|
<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="••••••••" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">视觉验证</label>
|
<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="grid grid-cols-[1fr_92px_44px] gap-3 items-center">
|
||||||
<div class="relative">
|
<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>
|
<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="验证码" />
|
<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>
|
||||||
<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>
|
<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">
|
<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>
|
<span class="material-symbols-outlined">refresh</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,29 +309,32 @@
|
|||||||
保持登录状态 24 小时
|
保持登录状态 24 小时
|
||||||
</label>
|
</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">
|
<button class="ui-btn ui-btn-lg ui-btn-primary w-full mt-2">
|
||||||
登录
|
登录
|
||||||
<span class="material-symbols-outlined text-lg">arrow_forward</span>
|
<span class="material-symbols-outlined text-lg">arrow_forward</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="{{ url_for('main.register') }}" class="space-y-5">
|
<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() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">用户名</label>
|
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">用户名</label>
|
||||||
<div class="relative">
|
<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>
|
<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="设置用户名" />
|
<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>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">密码</label>
|
<label class="block text-[11px] tracking-[0.18em] uppercase text-slate-500 mb-2">密码</label>
|
||||||
<div class="relative">
|
<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>
|
<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 位" />
|
<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>
|
||||||
</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">
|
<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>
|
<span class="material-symbols-outlined text-lg">person_add</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -184,14 +343,182 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="border-t border-slate-200 bg-[#f8fafc] px-8 py-5">
|
|
||||||
<div class="flex justify-end gap-6 text-[11px] text-slate-500">
|
|
||||||
<span>系统状态</span>
|
|
||||||
<span>服务条款</span>
|
|
||||||
<span>API 文档</span>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</section>
|
</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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
+79
-195
@@ -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" />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
|
|
||||||
<script>
|
|
||||||
tailwind.config = {
|
|
||||||
darkMode: 'class',
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
primary: '#005EB8',
|
|
||||||
primaryDeep: '#0c4188',
|
|
||||||
page: '#f3f5f8',
|
|
||||||
card: '#ffffff',
|
|
||||||
line: '#e5e7eb',
|
|
||||||
textMain: '#0f172a',
|
|
||||||
textSub: '#64748b',
|
|
||||||
blueSoft: '#eaf3ff',
|
|
||||||
bluePanel: '#1d4f9a',
|
|
||||||
outline: '#c7ced8',
|
|
||||||
successSoft: '#e9f8ee',
|
|
||||||
successText: '#16a34a',
|
|
||||||
warnSoft: '#fff4e8',
|
|
||||||
warnText: '#c2410c',
|
|
||||||
dangerSoft: '#fff0f0',
|
|
||||||
dangerText: '#dc2626',
|
|
||||||
lowCard: '#f8fafc'
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
headline: ['Manrope', 'Inter', 'sans-serif'],
|
|
||||||
body: ['Inter', 'sans-serif']
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
soft: '0 24px 24px -12px rgba(24,28,30,.06)',
|
|
||||||
card: '0 10px 25px rgba(15, 23, 42, .06)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
.material-symbols-outlined {
|
|
||||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
body { font-family: 'Inter', sans-serif; }
|
|
||||||
h1, h2, h3, h4 { font-family: 'Manrope', 'Inter', sans-serif; }
|
|
||||||
.dot-grid {
|
|
||||||
background-image: radial-gradient(circle at 1px 1px, rgba(148,163,184,.30) 1.2px, transparent 0);
|
|
||||||
background-size: 42px 42px;
|
|
||||||
}
|
|
||||||
.panel-frame {
|
|
||||||
box-shadow: none;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
.gradient-board {
|
|
||||||
background: linear-gradient(180deg, #2455a3 0%, #123e7d 100%);
|
|
||||||
}
|
|
||||||
.spinner {
|
|
||||||
width: 18px; height: 18px; border-radius: 9999px;
|
|
||||||
border: 2px solid rgba(255,255,255,.35); border-top-color: #fff;
|
|
||||||
animation: spin .75s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body class="bg-page min-h-screen text-textMain">
|
|
||||||
<header class="sticky top-0 z-30 bg-white border-b border-line">
|
|
||||||
<div class="px-6 lg:px-10">
|
|
||||||
<div class="h-16 flex items-center justify-between gap-4">
|
|
||||||
<div class="flex items-center gap-10">
|
|
||||||
<div class="flex items-center gap-2 text-[18px] font-extrabold tracking-tight">
|
|
||||||
<span class="material-symbols-outlined text-primary">water_drop</span>
|
|
||||||
<span>供水管道健康评估系统</span>
|
|
||||||
</div>
|
|
||||||
<nav class="hidden md:flex items-center gap-7 text-[13px] font-semibold self-stretch">
|
|
||||||
<a href="{{ url_for('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>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="px-6 lg:px-10 py-8">
|
|
||||||
{% if result %}
|
|
||||||
<div class="rounded-[14px] bg-blueSoft border border-blue-100 p-4 flex items-center justify-between gap-4 mb-8">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="w-12 h-12 rounded-xl bg-primary text-white flex items-center justify-center">
|
|
||||||
<span class="material-symbols-outlined">description</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div class="font-bold">数据分析报告: <span class="text-primary font-mono">{{ result.original_filename }}</span></div>
|
<h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">预测结果</h1>
|
||||||
<div class="text-xs text-textSub mt-1">基于上传数据生成的实时分析报告 • 生成于: {{ result.generated_at }}</div>
|
<p class="mt-2 text-sm text-textSub">文件:<span class="font-semibold text-textMain">{{ result.original_filename }}</span> · 生成于 {{ result.generated_at }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-4 text-xs font-semibold">
|
|
||||||
<div class="text-successText flex items-center gap-1"><span class="material-symbols-outlined text-base">check_circle</span>数据源已验证</div>
|
|
||||||
<a href="{{ url_for('main.home') }}" class="text-primary">返回主页</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-5 mb-6">
|
|
||||||
<h1 class="text-[64px] leading-none font-extrabold tracking-tight">预测结果</h1>
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<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">
|
<a href="{{ url_for('main.home') }}" class="ui-btn ui-btn-secondary">
|
||||||
<span class="material-symbols-outlined">refresh</span>重新运行分析
|
<span class="material-symbols-outlined text-lg">refresh</span>
|
||||||
|
重新分析
|
||||||
</a>
|
</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">
|
<a href="{{ result.excel_url }}" class="ui-btn ui-btn-primary">
|
||||||
<span class="material-symbols-outlined">download</span>导出预测报告 (Excel)
|
<span class="material-symbols-outlined text-lg">download</span>
|
||||||
|
导出结果表
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid lg:grid-cols-[1.08fr_.92fr] gap-6 items-start">
|
<div class="grid items-stretch gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||||
<section class="bg-[#fafafa] rounded-[18px] border border-slate-200 p-6 shadow-soft">
|
<section class="rounded-lg border border-line bg-white p-5 shadow-panel sm:p-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="mb-4">
|
||||||
<h2 class="text-[28px] font-extrabold">供水管道健康状态评估等级</h2>
|
<h2 class="text-xl font-extrabold">管道剩余寿命动态评估</h2>
|
||||||
<span class="px-3 py-1 rounded-full text-[11px] font-bold bg-slate-100 text-slate-500"></span>
|
<p class="mt-1 text-sm text-textSub">生存概率随时间变化的阶梯曲线。</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="rounded-md border border-line bg-slate-50 p-3">
|
||||||
<div class="grid grid-cols-2 xl:grid-cols-5 gap-3 mb-5 text-sm">
|
<img src="{{ result.image_url }}" alt="生存概率阶梯图" class="h-auto w-full rounded bg-white object-contain">
|
||||||
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div class="rounded-2xl bg-white border border-slate-200 overflow-hidden">
|
<aside class="flex flex-col gap-6">
|
||||||
|
<section class="rounded-lg border border-line bg-white p-5 shadow-panel">
|
||||||
|
<h2 class="text-lg font-extrabold">报告摘要</h2>
|
||||||
|
<dl class="mt-4 space-y-3 text-sm">
|
||||||
|
<div class="flex justify-between gap-4"><dt class="text-textSub">样本数量</dt><dd class="font-bold">{{ result.sample_count }}</dd></div>
|
||||||
|
<div class="flex justify-between gap-4"><dt class="text-textSub">显示样本</dt><dd class="font-bold">{{ result.summary_rows|length }}</dd></div>
|
||||||
|
<div class="flex justify-between gap-4"><dt class="text-textSub">输出文件</dt><dd class="font-bold">电子表格</dd></div>
|
||||||
|
</dl>
|
||||||
|
<a href="{{ result.excel_url }}" class="ui-btn ui-btn-secondary mt-5 w-full text-primary">
|
||||||
|
<span class="material-symbols-outlined text-lg">table_view</span>
|
||||||
|
查看完整样本列表
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="flex flex-1 flex-col rounded-lg border border-line bg-white p-5 shadow-panel">
|
||||||
|
<h2 class="text-lg font-extrabold">样本预览</h2>
|
||||||
|
<div class="mt-4 overflow-hidden rounded-md border border-line">
|
||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm">
|
||||||
<thead class="bg-slate-50 text-slate-500 text-xs uppercase tracking-[0.12em]">
|
<thead class="bg-slate-50 text-xs font-bold text-textSub">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-left px-5 py-4">管道编号</th>
|
<th class="px-3 py-3 text-left">管道编号</th>
|
||||||
<th class="text-left px-5 py-4">健康等级</th>
|
<th class="px-3 py-3 text-left">等级</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for item in result.summary_rows %}
|
{% for item in result.summary_rows %}
|
||||||
<tr class="border-t border-slate-100">
|
<tr class="border-t border-line">
|
||||||
<td class="px-5 py-4 font-semibold">{{ item.pipe_id }}</td>
|
<td class="px-3 py-3 font-semibold">{{ item.pipe_id }}</td>
|
||||||
<td class="px-5 py-4">
|
<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>
|
||||||
<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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</aside>
|
||||||
<section class="bg-[#fafafa] rounded-[18px] border border-slate-200 p-6 shadow-soft">
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-[28px] font-extrabold">管道剩余寿命动态评估</h2>
|
|
||||||
<p class="text-sm text-textSub mt-1">生存曲线拟合</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-xl border border-slate-200 p-4">
|
|
||||||
<img src="{{ result.image_url }}" alt="生存概率阶梯图" class="w-full h-auto object-contain">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 bg-blueSoft rounded-xl border border-blue-100 p-4 flex items-start gap-3 text-sm text-textSub leading-6">
|
|
||||||
<span class="material-symbols-outlined text-primary mt-0.5">info</span>
|
|
||||||
<p><span class="font-bold text-textMain">分析说明:</span>{{ result.analysis_text }}</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if result.importance_url %}
|
{% if result.importance_url %}
|
||||||
<section class="bg-[#fafafa] rounded-[18px] border border-slate-200 p-6 shadow-soft mt-6">
|
<section class="rounded-lg border border-line bg-white p-5 shadow-panel sm:p-6">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
<div class="mb-4">
|
||||||
<div>
|
<h2 class="text-xl font-extrabold">模型输入因素重要性排序</h2>
|
||||||
<h2 class="text-[28px] font-extrabold">模型输入因素重要性排序</h2>
|
<p class="mt-1 text-sm text-textSub">各输入因素对预测结果的相对影响程度。</p>
|
||||||
<p class="text-sm text-textSub mt-1">各输入因素对预测结果的相对影响程度</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="rounded-md border border-line bg-slate-50 p-3">
|
||||||
<div class="bg-white rounded-xl border border-slate-200 p-4">
|
<img src="{{ result.importance_url }}" alt="模型输入因素重要性排序图" class="h-auto w-full rounded bg-white object-contain">
|
||||||
<img src="{{ result.importance_url }}" alt="模型输入因素重要性排序图" class="w-full h-auto object-contain">
|
|
||||||
</div>
|
|
||||||
<div class="mt-5 bg-blueSoft rounded-xl border border-blue-100 p-4 flex items-start gap-3 text-sm text-textSub leading-6">
|
|
||||||
<span class="material-symbols-outlined text-primary mt-0.5">insights</span>
|
|
||||||
<p><span class="font-bold text-textMain">说明:</span>柱状图按重要性从高到低展示各输入因素对模型预测结果的相对贡献,数值为归一化占比,可用于辅助识别影响管道健康状态的关键因素。</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
|
||||||
<div class="rounded-2xl bg-white border border-slate-200 p-10 text-center text-textSub">当前还没有预测结果,请先从主页上传文件并运行预测。</div>
|
|
||||||
{% endif %}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="px-6 lg:px-10 py-6 border-t border-line text-[12px] text-slate-500 flex items-center justify-between">
|
<aside class="flex flex-col rounded-lg border border-line bg-white p-5 shadow-panel lg:col-start-2">
|
||||||
<span>预测结果仅供参考</span>
|
<h2 class="text-lg font-extrabold">健康等级说明</h2>
|
||||||
<div class="flex gap-6"><span></span><span></span><span></span><span></span></div>
|
<div class="mt-4 grid flex-1 gap-2 text-sm">
|
||||||
</footer>
|
<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>
|
||||||
</body>
|
<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>
|
||||||
</html>
|
<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 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 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>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<section class="rounded-lg border border-line bg-white p-10 text-center shadow-panel">
|
||||||
|
<span class="material-symbols-outlined text-5xl text-primary">monitoring</span>
|
||||||
|
<h1 class="mt-4 text-2xl font-extrabold">当前还没有预测结果</h1>
|
||||||
|
<p class="mt-2 text-sm text-textSub">请先从主页上传文件并运行预测。</p>
|
||||||
|
<a href="{{ url_for('main.home') }}" class="ui-btn ui-btn-primary mt-6">
|
||||||
|
<span class="material-symbols-outlined text-lg">upload_file</span>
|
||||||
|
去上传数据
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user