diff --git a/analysis_functions.py b/analysis_functions.py
new file mode 100644
index 0000000..c42140d
--- /dev/null
+++ b/analysis_functions.py
@@ -0,0 +1,208 @@
+import numpy as np
+import control as ct
+import matplotlib
+matplotlib.use('Agg')
+import matplotlib.pyplot as plt
+import re
+
+# --- 辅助函数:将系数数组转换为LaTeX数学公式字符串 ---
+def coeffs_to_latex(coeffs, var='s'):
+ """
+ 一个更健壮的函数,用于将系数数组转换为美观的LaTeX字符串。
+ """
+ coeffs = np.atleast_1d(coeffs)
+ if coeffs.size == 1:
+ return f"{coeffs[0]:g}"
+
+ latex_str = ""
+ degree = len(coeffs) - 1
+ for i, coeff in enumerate(coeffs):
+ if np.isclose(coeff, 0):
+ continue
+
+ sign = ""
+ if i > 0 and latex_str:
+ sign = " + " if coeff > 0 else " - "
+ elif coeff < 0:
+ sign = "-"
+
+ coeff_abs = abs(coeff)
+ coeff_str = f"{coeff_abs:g}" if not np.isclose(coeff_abs, 1) or degree - i == 0 else ""
+
+ power = degree - i
+ power_str = ""
+ if power > 0:
+ power_str = var
+ if power > 1:
+ power_str += f"^{{{power}}}"
+
+ latex_str += f"{sign}{coeff_str}{power_str}"
+
+ return latex_str if latex_str else "0"
+
+def _parse_coeffs(s_val):
+ """从字符串解析系数,返回Numpy数组"""
+ cleaned_str = re.sub(r'[^0-9,\-.]', '', s_val)
+ if not cleaned_str:
+ return np.array([])
+ return np.array([float(n) for n in cleaned_str.split(',') if n])
+
+# --- 功能函数1:显示传递函数 ---
+def display_transfer_function(num_str, den_str):
+ try:
+ num_coeffs = _parse_coeffs(num_str)
+ den_coeffs = _parse_coeffs(den_str)
+ if num_coeffs.size == 0 or den_coeffs.size == 0:
+ return "分子或分母不能为空"
+ num_latex = coeffs_to_latex(num_coeffs)
+ den_latex = coeffs_to_latex(den_coeffs)
+ return f"$$ G(s) = \\frac{{{num_latex}}}{{{den_latex}}} $$"
+ except Exception as e:
+ return f"输入格式错误: {e}"
+
+# --- 功能函数2:执行时域分析和绘图 ---
+def time_domain_analysis(num_str, den_str):
+ try:
+ num = _parse_coeffs(num_str)
+ den = _parse_coeffs(den_str)
+ if num.size == 0 or den.size == 0:
+ return None, "错误:分子或分母系数不能为空。"
+
+ system = ct.TransferFunction(num, den)
+ t = np.linspace(0, 15, 1000)
+ T_step, yout_step = ct.step_response(system, T=t)
+ T_impulse, yout_impulse = ct.impulse_response(system, T=t)
+
+ metrics_text = "无法计算性能指标。"
+ try:
+ info = ct.step_info(system)
+ if isinstance(info, dict):
+ metrics_text = (
+ f"上升时间: {info.get('RiseTime', float('nan')):.2f} s\n"
+ f"峰值时间: {info.get('PeakTime', float('inf')):.2f} s\n"
+ f"峰值: {info.get('Peak', float('inf')):.2f}\n"
+ f"超调量: {info.get('Overshoot', float('nan')):.1f} %\n"
+ f"调节时间: {info.get('SettlingTime', float('nan')):.2f} s\n"
+ f"稳态值: {info.get('SteadyStateValue', float('nan')):.2f}"
+ )
+ except (RuntimeError, Exception):
+ metrics_text = "系统可能不稳定,无法计算阶跃响应指标。"
+
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))
+ ax1.plot(T_step, yout_step); ax1.set_title("Unit Step Response"); ax1.set_xlabel("Time (s)"); ax1.set_ylabel("Amplitude"); ax1.grid(True)
+ ax2.plot(T_impulse, yout_impulse); ax2.set_title("Unit Impulse Response"); ax2.set_xlabel("Time (s)"); ax2.set_ylabel("Amplitude"); ax2.grid(True)
+ plt.tight_layout()
+
+ return fig, metrics_text
+ except Exception as e:
+ return None, f"错误: {e}\n请检查您的输入。"
+
+# --- 功能函数3:执行频域分析和绘图 ---
+def frequency_domain_analysis(num_str, den_str, k):
+ try:
+ num_coeffs = _parse_coeffs(num_str)
+ den_coeffs = _parse_coeffs(den_str)
+
+ if num_coeffs.size == 0 or den_coeffs.size == 0:
+ return None, "错误:系数不能为空。", "", ""
+
+ system_with_gain = ct.TransferFunction(k * num_coeffs, den_coeffs)
+ tf_latex_with_gain = f"$$ G_{{open}}(s) = K \\times \\frac{{{coeffs_to_latex(num_coeffs)}}}{{{coeffs_to_latex(den_coeffs)}}} \\quad \\text{{where }} K = {k:.2f} $$"
+
+ fig = plt.figure(figsize=(12, 6)); gs = fig.add_gridspec(2, 2)
+ ax_mag = fig.add_subplot(gs[0, 0]); ax_phase = fig.add_subplot(gs[1, 0], sharex=ax_mag)
+ omega_range = np.logspace(-2, 3, 1000)
+ mag, phase, omega = ct.frequency_response(system_with_gain, omega_range)
+
+ ax_mag.semilogx(omega, 20 * np.log10(mag)); ax_mag.grid(True, which='both'); ax_mag.set_ylabel("Magnitude (dB)"); ax_mag.set_title("Bode Plot")
+ ax_phase.semilogx(omega, np.rad2deg(phase)); ax_phase.grid(True, which='both'); ax_phase.set_ylabel("Phase (deg)"); ax_phase.set_xlabel("Frequency (rad/s)"); ax_phase.axhline(y=-180, color='r', linestyle='--', linewidth=0.8)
+
+ ax_nyquist = fig.add_subplot(gs[:, 1])
+ ct.nyquist_plot(system_with_gain, ax=ax_nyquist); ax_nyquist.set_title("Nyquist Plot"); ax_nyquist.grid(True)
+ plt.tight_layout()
+
+ metrics_text, stability_text = "无法计算稳定裕度。", "**评估**: **无法判断**"
+ try:
+ gm, pm, _, _ = ct.margin(system_with_gain)
+ gm_db = 20 * np.log10(gm) if gm > 0 and np.isfinite(gm) else float('inf')
+
+ is_stable = gm_db > 0 and pm > 0
+ stability_text = f"**评估**: **{'系统稳定' if is_stable else '系统不稳定'}**"
+
+ metrics_text = ""
+ if np.isinf(gm_db): metrics_text += f"增益裕度 (GM): inf dB\n(注: 相位未穿过-180°线)\n"
+ else: metrics_text += f"增益裕度 (GM): {gm_db:.2f} dB\n"
+ if np.isinf(pm): metrics_text += f"相位裕度 (PM): 未定义"
+ else: metrics_text += f"相位裕度 (PM): {pm:.2f} deg"
+ except Exception:
+ pass
+
+ return fig, metrics_text, tf_latex_with_gain, stability_text
+ except Exception as e:
+ return None, f"错误: {e}", "", ""
+
+# --- 功能函数4:执行根轨迹分析 ---
+def root_locus_analysis(num_str, den_str, log_k):
+ try:
+ k = 10**log_k
+ num_coeffs = _parse_coeffs(num_str)
+ den_coeffs = _parse_coeffs(den_str)
+
+ if num_coeffs.size == 0 or den_coeffs.size == 0:
+ return None, "错误:系数不能为空。", k
+
+ open_loop_system = ct.TransferFunction(num_coeffs, den_coeffs)
+ fig, ax = plt.subplots(figsize=(8, 6))
+
+ closed_loop_system = ct.feedback(k * open_loop_system, 1)
+ current_poles = ct.poles(closed_loop_system)
+
+ # 绘制根轨迹
+ rlist, klist = ct.root_locus(open_loop_system, plot=False, grid=False)
+ for i in range(rlist.shape[1]):
+ ax.plot(np.real(rlist[:, i]), np.imag(rlist[:, i]), 'b-', linewidth=1.5, alpha=0.7)
+
+ # 只标记当前增益下的闭环极点
+ ax.plot(np.real(current_poles), np.imag(current_poles), 'rx',
+ markersize=12, markeredgewidth=2.5, label=f'Poles at K={k:.2f}', zorder=5)
+
+ # 动态计算坐标轴范围 - 只基于当前闭环极点位置
+ pole_real = np.real(current_poles)
+ pole_imag = np.imag(current_poles)
+
+ # 找到极点的最大绝对值(实部和虚部)
+ max_real = np.max(np.abs(pole_real))
+ max_imag = np.max(np.abs(pole_imag))
+
+ # 取实部和虚部的最大值作为基准
+ max_val = max(max_real, max_imag)
+
+ # 确保最小显示范围
+ if max_val < 1.0:
+ max_val = 1.0
+
+ # 添加 30% 的边距,使图像更美观
+ margin = 1.3
+ limit = max_val * margin
+
+ # 设置对称的坐标轴范围(以原点为中心)
+ ax.set_xlim(-limit, limit)
+ ax.set_ylim(-limit, limit)
+
+ # 绘制虚轴(稳定性边界)和实轴
+ ax.axvline(x=0, color='k', linestyle='--', linewidth=0.8, alpha=0.5)
+ ax.axhline(y=0, color='k', linestyle='-', linewidth=0.5, alpha=0.3)
+
+ ax.set_xlabel("Real Axis")
+ ax.set_ylabel("Imaginary Axis")
+ ax.set_title("Root Locus")
+ ax.grid(True, alpha=0.3)
+ ax.legend(loc='best', fontsize=9)
+ ax.set_aspect('equal', adjustable='box')
+
+ poles_text = "Closed-Loop Poles:\n"
+ for p in current_poles:
+ poles_text += f"{p.real:.3f} {'+' if p.imag >= 0 else '-'} {abs(p.imag):.3f}j\n"
+ return fig, poles_text, k
+ except Exception as e:
+ return None, f"错误: {e}", 10**log_k
diff --git a/app.py b/app.py
index 0997258..dcd143f 100644
--- a/app.py
+++ b/app.py
@@ -1,2331 +1,191 @@
-"""
-================================================================================
-自动控制原理AI+数智平台 (Automatic Control Theory AI+ Platform)
-================================================================================
-
-版权声明 (Copyright Notice):
- 本项目由"西北工业大学2025年校级本科生建设项目"资助
- Funded by Northwestern Polytechnical University 2025 Undergraduate
- Construction Project
-
-项目信息 (Project Information):
- 课程名称:《自动控制理论》
- Course: Automatic Control Theory
-
- 负责人:魏鹏飞
- Supervisor: Wei Pengfei
-
- 联系方式:pengfeiwei@nwpu.edu.cn
- Email: pengfeiwei@nwpu.edu.cn
-
- 机构:西北工业大学
- Institution: Northwestern Polytechnical University (NWPU)
-
-功能简介 (Features):
- - 时域分析:阶跃响应、脉冲响应、性能指标计算
- - 频域分析:Bode图、Nyquist图、稳定裕度分析
- - 根轨迹分析:动态轨迹绘制、增益调节、极点跟踪
- - AI智能问答:基于DeepSeek/Gemini的专业教学助手
-
-最后更新 (Last Updated): 2025-10-16
-版本 (Version): 1.0.0
-
-================================================================================
-"""
-
import gradio as gr
-import numpy as np
-import control as ct
-import matplotlib
-matplotlib.use('Agg')
-import matplotlib.pyplot as plt
-import re
-
-# ==================== API 配置 ====================
-# 将 API 相关配置集中在此处,方便修改
-#
-# DeepSeek API 配置说明:
-# 1. API_KEY: 您的 DeepSeek API 密钥
-# - 从 https://platform.deepseek.com/api_keys 获取
-# - 或使用环境变量: API_KEY = os.environ.get("DEEPSEEK_API_KEY", "")
-#
-# 2. API_BASE_URL: API 服务的基础 URL
-# - 官方地址: https://api.deepseek.com/v1
-# - DeepSeek API 兼容 OpenAI 格式
-#
-# 3. API_MODEL: 使用的 DeepSeek 模型名称
-# - deepseek-chat (推荐,性能强大)
-# - deepseek-coder (代码专用)
-# - 其他可用模型请参考官方文档
-
-API_KEY = "sk-2292af2428d7419897ca1fb6e99ba6bc" # 请在此处填入您的 DeepSeek API 密钥
-API_BASE_URL = "https://api.deepseek.com/v1" # API 基础 URL
-API_MODEL = "deepseek-chat" # 使用的模型名称
-API_TYPE = "deepseek" # API 类型: "deepseek" 或 "gemini"
-# ==================================================
-import json
-import os
import time
-from datetime import datetime
-from threading import Lock
-import atexit
-
-"""
-==================== 在线/总人数统计与持久化 ====================
-新增:在项目根目录下自动创建数据文件夹,持久化累计人数,
-防止容器重启后总人数从 0 开始。
-保存策略:每 SAVE_INTERVAL_SECONDS 秒写盘一次(由在线人数刷新触发)。
-"""
-# 当前活跃用户(在线)
-active_users = {} # {session_id: last_active_timestamp}
-users_lock = Lock()
-TIMEOUT_SECONDS = 300 # 5分钟无活动视为离线
-
-# 累计人数(总人数)持久化
-stats_lock = Lock()
-STATS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data_usage")
-STATS_FILE = os.path.join(STATS_DIR, "usage_stats.json")
-SAVE_INTERVAL_SECONDS = int(os.environ.get("USAGE_SAVE_INTERVAL", "60")) # 默认60秒写盘一次
-total_users = 0 # 累计会话人数(近似代表累计访问人数)
-seen_sessions = set() # 仅在本次进程内去重,避免同一session重复计数
-_LAST_SAVE_TS = 0.0
-
-def _ensure_stats_dir():
- try:
- os.makedirs(STATS_DIR, exist_ok=True)
- except Exception:
- # 即便目录创建失败,不影响主流程,仅导致不持久化
- pass
-
-def _load_usage_stats():
- """从磁盘加载累计人数,若无文件则从0开始。"""
- global total_users, _LAST_SAVE_TS
- try:
- if os.path.exists(STATS_FILE):
- with open(STATS_FILE, "r", encoding="utf-8") as f:
- data = json.load(f)
- total_users = int(data.get("total_users", 0))
- _LAST_SAVE_TS = float(data.get("last_saved_at", time.time()))
- else:
- total_users = 0
- _LAST_SAVE_TS = time.time()
- except Exception:
- # 文件损坏或解析失败时,安全回退为0
- total_users = 0
- _LAST_SAVE_TS = time.time()
-
-def _save_usage_stats():
- """将累计人数持久化到磁盘。"""
- global _LAST_SAVE_TS
- try:
- tmp_path = STATS_FILE + ".tmp"
- with open(tmp_path, "w", encoding="utf-8") as f:
- json.dump({"total_users": total_users, "last_saved_at": time.time()}, f, ensure_ascii=False, indent=2)
- os.replace(tmp_path, STATS_FILE)
- _LAST_SAVE_TS = time.time()
- except Exception:
- # 写盘失败不阻塞主流程
- pass
-
-def _maybe_save_usage_stats():
- """按间隔条件触发写盘。调用点:在线人数UI刷新时。"""
- try:
- with stats_lock:
- if time.time() - _LAST_SAVE_TS >= SAVE_INTERVAL_SECONDS:
- _save_usage_stats()
- except Exception:
- pass
-
-# 初始化目录与读取历史累计值
-_ensure_stats_dir()
-_load_usage_stats()
-atexit.register(_save_usage_stats)
-
-def get_active_users_count():
- """获取当前活跃用户数量"""
- current_time = time.time()
- with users_lock:
- # 移除超时用户
- expired_users = [uid for uid, last_time in active_users.items()
- if current_time - last_time > TIMEOUT_SECONDS]
- for uid in expired_users:
- del active_users[uid]
- return len(active_users)
-
-def update_user_activity(session_id):
- """更新用户活跃时间并在首次出现时累计总人数。"""
- # 更新在线用户心跳
- with users_lock:
- active_users[session_id] = time.time()
- # 首次见到该 session,累计 +1(仅在本进程内去重),并等待定时写盘
- try:
- with stats_lock:
- global total_users
- if session_id not in seen_sessions:
- seen_sessions.add(session_id)
- total_users += 1
- except Exception:
- # 统计异常不影响主流程
- pass
-
-def get_online_status_html():
- """生成在线人数与总人数的显示HTML,并按需触发持久化。"""
- count = get_active_users_count()
- # 读取累计人数(读锁即可)
- try:
- with stats_lock:
- total = int(total_users)
- except Exception:
- total = 0
- # 触发间隔写盘
- _maybe_save_usage_stats()
- return f"""
-
- 🌐
- 在线人数:
- {count}
- |
- 👥
- 总人数:
- {total}
-
- """
-# ==================================================
-
-# --- [FIXED] 辅助函数:将系数数组转换为LaTeX数学公式字符串 ---
-def coeffs_to_latex(coeffs, var='s'):
- """
- 一个更健壮的函数,用于将系数数组转换为美观的LaTeX字符串。
- """
- coeffs = np.atleast_1d(coeffs)
- # 如果只有一个系数(常数),直接返回
- if coeffs.size == 1:
- return f"{coeffs[0]:g}"
-
- latex_str = ""
- degree = len(coeffs) - 1
- for i, coeff in enumerate(coeffs):
- # 跳过系数为0的项
- if np.isclose(coeff, 0):
- continue
-
- # 确定符号
- sign = ""
- if i > 0 and latex_str:
- if coeff > 0:
- sign = " + "
- else:
- sign = " - "
- elif coeff < 0:
- sign = "-"
-
- # 处理绝对值
- coeff_abs = abs(coeff)
-
- # 处理系数的显示
- coeff_str = ""
- # 仅当系数不为1或为常数项时显示系数
- if not np.isclose(coeff_abs, 1) or degree - i == 0:
- coeff_str = f"{coeff_abs:g}"
-
- # 处理变量和幂
- power = degree - i
- power_str = ""
- if power > 0:
- power_str = var
- if power > 1:
- power_str += f"^{{{power}}}"
-
- latex_str += f"{sign}{coeff_str}{power_str}"
-
- return latex_str if latex_str else "0"
-
-
-# --- 功能函数1:显示传递函数 ---
-def display_transfer_function(num_str, den_str, session_id):
- update_user_activity(session_id)
- try:
- num_str_cleaned = re.sub(r'[^0-9,\-.]', '', num_str)
- den_str_cleaned = re.sub(r'[^0-9,\-.]', '', den_str)
- num_coeffs = np.array([float(n) for n in num_str_cleaned.split(',') if n])
- den_coeffs = np.array([float(d) for d in den_str_cleaned.split(',') if d])
- if num_coeffs.size == 0 or den_coeffs.size == 0:
- return "分子或分母不能为空", get_online_status_html()
- num_latex = coeffs_to_latex(num_coeffs)
- den_latex = coeffs_to_latex(den_coeffs)
- tf_latex = f"$$ G(s) = \\frac{{{num_latex}}}{{{den_latex}}} $$"
- return tf_latex, get_online_status_html()
- except Exception as e:
- return f"输入格式错误: {e}", get_online_status_html()
-
-# --- 功能函数2:执行时域分析和绘图 ---
-def time_domain_analysis(num_str, den_str, session_id):
- update_user_activity(session_id)
- try:
- num_str_cleaned = re.sub(r'[^0-9,\-.]', '', num_str)
- den_str_cleaned = re.sub(r'[^0-9,\-.]', '', den_str)
- num = np.array([float(n) for n in num_str_cleaned.split(',') if n])
- den = np.array([float(d) for d in den_str_cleaned.split(',') if d])
- if num.size == 0 or den.size == 0:
- return None, "错误:分子或分母系数不能为空。", get_online_status_html()
- system = ct.TransferFunction(num, den)
- t = np.linspace(0, 15, 1000)
- T_step, yout_step = ct.step_response(system, T=t)
- T_impulse, yout_impulse = ct.impulse_response(system, T=t)
-
- try:
- info = ct.step_info(system)
- if isinstance(info, dict):
- metrics_text = (
- f"Rise Time: {info.get('RiseTime', float('nan')):.2f} s\n"
- f"Peak Time: {info.get('PeakTime', float('inf')):.2f} s\n"
- f"Peak: {info.get('Peak', float('inf')):.2f}\n"
- f"Overshoot: {info.get('Overshoot', float('nan')):.1f} %\n"
- f"Settling Time: {info.get('SettlingTime', float('nan')):.2f} s\n"
- f"Steady State Value: {info.get('SteadyStateValue', float('nan')):.2f}"
- )
- else:
- metrics_text = "系统性能指标计算失败。"
- except RuntimeError:
- metrics_text = "系统可能不稳定,无法计算阶跃响应指标。"
- except Exception:
- metrics_text = "无法计算所有性能指标。"
-
- fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))
-
- ax1.plot(T_step, yout_step); ax1.set_title("Unit Step Response"); ax1.set_xlabel("Time (s)"); ax1.set_ylabel("Amplitude"); ax1.grid(True)
- ax2.plot(T_impulse, yout_impulse); ax2.set_title("Unit Impulse Response"); ax2.set_xlabel("Time (s)"); ax2.set_ylabel("Amplitude"); ax2.grid(True)
-
- plt.tight_layout()
-
- return fig, metrics_text, get_online_status_html()
- except Exception as e:
- return None, f"错误: {e}\n请检查您的输入。", get_online_status_html()
-
-# --- 功能函数3:执行频域分析和绘图 ---
-def frequency_domain_analysis(num_str, den_str, k, session_id):
- update_user_activity(session_id)
- try:
- num_str_cleaned = re.sub(r'[^0-9,\-.]', '', num_str)
- den_str_cleaned = re.sub(r'[^0-9,\-.]', '', den_str)
- num_coeffs = np.array([float(n) for n in num_str_cleaned.split(',') if n])
- den_coeffs = np.array([float(d) for d in den_str_cleaned.split(',') if d])
-
- if num_coeffs.size == 0 or den_coeffs.size == 0:
- return None, "错误:分子或分母系数不能为空。", "", ""
-
- system_with_gain = ct.TransferFunction(k * num_coeffs, den_coeffs)
-
- num_latex = coeffs_to_latex(num_coeffs)
- den_latex = coeffs_to_latex(den_coeffs)
-
- tf_latex_with_gain = f"$$ G_{{open}}(s) = K \\times \\frac{{{num_latex}}}{{{den_latex}}} \\quad \\text{{where }} K = {k:.2f} $$"
-
- fig = plt.figure(figsize=(12, 6)); gs = fig.add_gridspec(2, 2)
- ax_mag = fig.add_subplot(gs[0, 0]); ax_phase = fig.add_subplot(gs[1, 0], sharex=ax_mag)
- omega_range = np.logspace(-2, 3, 1000)
- mag, phase, omega = ct.frequency_response(system_with_gain, omega_range)
-
- ax_mag.semilogx(omega, 20 * np.log10(mag)); ax_mag.grid(True, which='both'); ax_mag.set_ylabel("Magnitude (dB)"); ax_mag.set_title("Bode Plot")
- ax_phase.semilogx(omega, np.rad2deg(phase)); ax_phase.grid(True, which='both'); ax_phase.set_ylabel("Phase (deg)"); ax_phase.set_xlabel("Frequency (rad/s)"); ax_phase.axhline(y=-180, color='r', linestyle='--', linewidth=0.8)
-
- ax_nyquist = fig.add_subplot(gs[:, 1])
- ct.nyquist_plot(system_with_gain, ax=ax_nyquist); ax_nyquist.set_title("Nyquist Plot"); ax_nyquist.grid(True)
- plt.tight_layout()
-
- try:
- gm, pm, _, _ = ct.margin(system_with_gain)
- gm_db = 20 * np.log10(gm) if gm > 0 and np.isfinite(gm) else float('inf')
-
- is_stable = gm_db > 0 and pm > 0
- stability_text = f"**Evaluation**: **{'System Stable' if is_stable else 'System Unstable'}**"
-
- metrics_text = ""
- if np.isinf(gm_db): metrics_text += f"Gain Margin (GM): inf dB\n(Note: Phase never crosses -180° line)\n"
- else: metrics_text += f"Gain Margin (GM): {gm_db:.2f} dB\n"
- if np.isinf(pm): metrics_text += f"Phase Margin (PM): not defined"
- else: metrics_text += f"Phase Margin (PM): {pm:.2f} deg"
- except Exception as e:
- metrics_text = f"Unable to compute stability margins."; stability_text = "**Evaluation**: **Cannot determine**"
-
- return fig, metrics_text, tf_latex_with_gain, stability_text
- except Exception as e:
- return None, f"错误: {e}", "", ""
-
-# --- 功能函数4:执行根轨迹分析 ---
-def root_locus_analysis(num_str, den_str, log_k, session_id):
- update_user_activity(session_id)
- try:
- k = 10**log_k
- num_str_cleaned = re.sub(r'[^0-9,\-.]', '', num_str)
- den_str_cleaned = re.sub(r'[^0-9,\-.]', '', den_str)
- num_coeffs = np.array([float(n) for n in num_str_cleaned.split(',') if n])
- den_coeffs = np.array([float(d) for d in den_str_cleaned.split(',') if d])
-
- if num_coeffs.size == 0 or den_coeffs.size == 0:
- return None, "错误:分子或分母系数不能为空。", k
-
- open_loop_system = ct.TransferFunction(num_coeffs, den_coeffs)
- fig, ax = plt.subplots(figsize=(8, 6))
-
- closed_loop_system = ct.feedback(k * open_loop_system, 1)
- current_poles = ct.poles(closed_loop_system)
- ol_poles = open_loop_system.poles(); ol_zeros = open_loop_system.zeros()
- points_of_interest = np.concatenate(([0j], ol_poles, ol_zeros, current_poles))
-
- min_real = np.min(np.real(points_of_interest)); max_real = np.max(np.real(points_of_interest))
- min_imag = np.min(np.imag(points_of_interest)); max_imag = np.max(np.imag(points_of_interest))
-
- center_real = (max_real + min_real) / 2; span_real = max(abs(max_real - min_real), 2) * 1.5
- center_imag = (max_imag + min_imag) / 2; span_imag = max(abs(max_imag - min_imag), 2) * 1.5
- max_span = max(span_real, span_imag)
-
- rlist, klist = ct.root_locus(open_loop_system, plot=False, grid=False)
- for i in range(rlist.shape[1]): ax.plot(np.real(rlist[:, i]), np.imag(rlist[:, i]), 'b-')
-
- ax.set_xlim(center_real - max_span / 2, center_real + max_span / 2); ax.set_ylim(center_imag - max_span / 2, center_imag + max_span / 2)
- ax.plot(np.real(current_poles), np.imag(current_poles), 'rx', markersize=10, markeredgewidth=2, label=f'Poles at K={k:.2f}')
- ax.set_xlabel("Real Axis"); ax.set_ylabel("Imaginary Axis"); ax.set_title("Root Locus"); ax.grid(True); ax.legend(loc='upper right'); ax.set_aspect('equal', adjustable='box')
-
- poles_text = "Closed-Loop Poles:\n"
- for p in current_poles: poles_text += f"{p.real:.3f} {'+' if p.imag >= 0 else '-'} {abs(p.imag):.3f}j\n"
- return fig, poles_text, k
- except Exception as e:
- return None, f"错误: {e}", 10**log_k
-
-# --- [新增] 功能函数5: AI 智能问答 (支持 DeepSeek 和 Gemini) ---
-# [已修复] 兼容新版 Gradio 的 Chatbot 格式
-async def chat_with_ai(message, history, session_id):
- """
- 与 AI 模型进行流式对话。支持 DeepSeek 和 Gemini API。
- 使用新版 Gradio 的 'messages' 格式。
- """
- update_user_activity(session_id)
- # 系统指令
- system_prompt = "你是一位精通自动控制原理的专家教授。请用清晰、准确、专业的中文来回答有关自动控制课程内容的问题。在适当的时候,可以使用公式和示例来辅助解释。"
-
- # 检查 API_KEY
- if not API_KEY or API_KEY.strip() == "":
- history.append({"role": "assistant", "content": "❌ 错误:API_KEY 未配置。请在文件开头配置 API_KEY。"})
- yield history
- return
-
- # 将用户的新消息添加到历史记录中
- history.append({"role": "user", "content": message})
-
- # 添加一个临时的 "正在思考" 消息
- history.append({"role": "assistant", "content": "正在思考..."})
- yield history
-
- bot_response = ""
-
- try:
- import aiohttp
-
- if API_TYPE == "deepseek":
- api_url = f"{API_BASE_URL}/chat/completions"
-
- # 构造发送到 API 的消息 (不包括我们临时的 '正在思考' 消息)
- messages_for_api = [{"role": "system", "content": system_prompt}] + history[:-1]
-
- payload = {
- "model": API_MODEL,
- "messages": messages_for_api,
- "stream": True, "temperature": 0.7, "max_tokens": 2048
- }
- headers = {
- "Content-Type": "application/json",
- "Authorization": f"Bearer {API_KEY}"
- }
-
- async with aiohttp.ClientSession() as session:
- async with session.post(api_url, json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=60)) as response:
- if response.status == 200:
- async for line in response.content:
- # (此处省略了流式处理的细节,和您原代码一致,但更新了history的修改方式)
- line = line.decode('utf-8').strip()
- if not line or line == "data: [DONE]": continue
- if line.startswith("data: "): line = line[6:]
- try:
- data = json.loads(line)
- if "choices" in data and len(data["choices"]) > 0:
- delta = data["choices"][0].get("delta", {})
- content = delta.get("content", "")
- if content:
- bot_response += content
- history[-1]["content"] = bot_response # 更新最后一条消息
- yield history
- except json.JSONDecodeError: pass
-
- if not bot_response:
- history[-1]["content"] = "⚠️ API 返回了空响应,请稍后重试。"
- yield history
- else:
- error_text = await response.text()
- history[-1]["content"] = f"❌ API请求出错 (状态码: {response.status}):\n{error_text}"
- yield history
-
- else: # Gemini API
- history[-1]["content"] = "❌ Gemini API 的逻辑当前未在此修复中实现。"
- yield history
-
- except aiohttp.ClientError as e:
- history[-1]["content"] = f"❌ 网络连接错误: {e}"
- yield history
- except Exception as e:
- history[-1]["content"] = f"❌ 发生错误: {type(e).__name__}: {e}"
- yield history
-
-
-# --- Gradio 界面定义 ---
-# 自定义 CSS 样式 - 全面现代化升级
-custom_css = """
-/* ==================== 全局样式 ==================== */
-* {
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
-}
-
-.gradio-container {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif !important;
- background: linear-gradient(135deg, #f5f7fa 0%, #e8eef5 100%) !important;
-}
-
-/* ==================== Emoji 显示修复 ==================== */
-/* 确保所有 emoji 正常显示,不被背景色覆盖 */
-* {
- font-feature-settings: "liga" 1, "calt" 1, "kern" 1;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-}
-
-/* 针对 Gradio Markdown 组件的 emoji 修复 */
-.gr-markdown code {
- background: transparent !important;
- padding: 0 !important;
- font-family: inherit !important;
-}
-
-/* 确保 HTML 内容中的 emoji 不受影响 */
-.gr-html * {
- background-clip: border-box !important;
- -webkit-background-clip: border-box !important;
- -webkit-text-fill-color: initial !important;
-}
-
-/* ==================== 标题区域 ==================== */
-.main-title {
- text-align: center;
- font-size: 3em !important;
- font-weight: 900 !important;
- margin-bottom: 0.3em;
- letter-spacing: -1px;
- animation: titleGlow 3s ease-in-out infinite;
- color: #333;
-}
-
-/* 标题渐变效果(不影响emoji) */
-h1.main-title {
- background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
- -webkit-background-clip: text;
- -webkit-text-fill-color: transparent;
- background-clip: text;
-}
-
-@keyframes titleGlow {
- 0%, 100% { filter: brightness(1); }
- 50% { filter: brightness(1.2); }
-}
-
-.subtitle {
- text-align: center;
- color: #555;
- font-size: 1.2em;
- font-weight: 500;
- margin-bottom: 1.5em;
- letter-spacing: 0.5px;
-}
-
-/* 确保 subtitle 中的 emoji 正常显示 */
-p.subtitle {
- color: #555 !important;
-}
-
-/* ==================== 项目信息横幅 ==================== */
-.project-info-banner {
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- color: white;
- padding: 15px 20px;
- border-radius: 12px;
- margin-bottom: 25px;
- box-shadow: 0 8px 32px rgba(102, 126, 234, 0.3);
- animation: bannerSlide 0.6s ease-out;
-}
-
-@keyframes bannerSlide {
- from { transform: translateY(-20px); opacity: 0; }
- to { transform: translateY(0); opacity: 1; }
-}
-
-/* ==================== 标签页样式 ==================== */
-.tab-nav {
- background: white;
- border-radius: 12px;
- padding: 8px;
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
- margin-bottom: 20px;
-}
-
-.tab-nav button {
- font-weight: 600 !important;
- font-size: 1.1em !important;
- padding: 12px 24px !important;
- border-radius: 8px !important;
- border: none !important;
- transition: all 0.3s ease !important;
-}
-
-.tab-nav button:hover {
- background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%) !important;
- transform: translateY(-2px) !important;
-}
-
-.tab-nav button.selected {
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
- color: white !important;
- box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4) !important;
-}
-
-/* ==================== 卡片和分组样式 ==================== */
-.gr-group {
- background: white !important;
- border-radius: 16px !important;
- padding: 24px !important;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08) !important;
- border: 1px solid rgba(102, 126, 234, 0.1) !important;
- margin-bottom: 20px !important;
- transition: transform 0.3s ease, box-shadow 0.3s ease !important;
-}
-
-.gr-group:hover {
- transform: translateY(-4px) !important;
- box-shadow: 0 12px 48px rgba(102, 126, 234, 0.15) !important;
-}
-
-/* 卡片标题 */
-.card-title {
- font-size: 1.4em;
- font-weight: 700;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- -webkit-background-clip: text;
- -webkit-text-fill-color: transparent;
- background-clip: text;
- margin-bottom: 15px;
- border-bottom: 3px solid;
- border-image: linear-gradient(90deg, #667eea, #764ba2) 1;
- padding-bottom: 10px;
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-/* 修复 emoji 显示问题 - emoji 不应用渐变色 */
-.card-title::before {
- content: '';
- display: inline-block;
-}
-
-/* 确保 HTML 内的 emoji 正常显示 */
-div.card-title {
- background: transparent !important;
- -webkit-background-clip: initial !important;
- -webkit-text-fill-color: initial !important;
- background-clip: initial !important;
- color: #333 !important;
-}
-
-/* 只对文字部分应用渐变(如果需要) */
-div.card-title span {
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- -webkit-background-clip: text;
- -webkit-text-fill-color: transparent;
- background-clip: text;
-}
-
-/* ==================== 输入框样式 ==================== */
-.gr-textbox input, .gr-textbox textarea {
- border: 2px solid #e0e7ff !important;
- border-radius: 10px !important;
- padding: 12px 16px !important;
- font-size: 1em !important;
- transition: all 0.3s ease !important;
- background: #fafbff !important;
-}
-
-.gr-textbox input:focus, .gr-textbox textarea:focus {
- border-color: #667eea !important;
- box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1) !important;
- background: white !important;
- transform: translateY(-2px);
-}
-
-.gr-textbox label {
- font-weight: 600 !important;
- color: #4a5568 !important;
- margin-bottom: 8px !important;
-}
-
-/* ==================== 按钮样式 ==================== */
-button {
- border-radius: 10px !important;
- font-weight: 600 !important;
- padding: 12px 24px !important;
- font-size: 1em !important;
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
- border: none !important;
- cursor: pointer !important;
-}
-
-.primary-btn, button[variant="primary"] {
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
- color: white !important;
- box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4) !important;
-}
-
-.primary-btn:hover, button[variant="primary"]:hover {
- transform: translateY(-3px) scale(1.02) !important;
- box-shadow: 0 8px 25px rgba(102, 126, 234, 0.5) !important;
-}
-
-.primary-btn:active, button[variant="primary"]:active {
- transform: translateY(-1px) scale(0.98) !important;
-}
-
-button[variant="secondary"] {
- background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%) !important;
- color: white !important;
- box-shadow: 0 4px 15px rgba(240, 147, 251, 0.4) !important;
-}
-
-button[variant="secondary"]:hover {
- transform: translateY(-3px) scale(1.02) !important;
- box-shadow: 0 8px 25px rgba(240, 147, 251, 0.5) !important;
-}
-
-/* ==================== 滑块样式 ==================== */
-.gr-slider {
- padding: 20px 10px !important;
-}
-
-.gr-slider input[type="range"] {
- height: 8px !important;
- border-radius: 4px !important;
- background: linear-gradient(90deg, #667eea 0%, #764ba2 100%) !important;
-}
-
-.gr-slider input[type="range"]::-webkit-slider-thumb {
- width: 20px !important;
- height: 20px !important;
- background: white !important;
- border: 3px solid #667eea !important;
- box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4) !important;
- cursor: pointer !important;
-}
-
-.gr-slider input[type="range"]::-webkit-slider-thumb:hover {
- transform: scale(1.2) !important;
- box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6) !important;
-}
-
-/* ==================== 图表容器 ==================== */
-.plot-container {
- border-radius: 16px !important;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08) !important;
- overflow: hidden !important;
- background: white !important;
- padding: 10px !important;
- border: 1px solid rgba(102, 126, 234, 0.1) !important;
-}
-
-.gr-plot {
- border-radius: 12px !important;
-}
-
-/* ==================== 知识卡片 ==================== */
-.knowledge-card {
- background: linear-gradient(135deg, #ffffff 0%, #f8f9ff 100%);
- border-radius: 16px;
- padding: 24px;
- margin-top: 20px;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
- max-height: 550px;
- overflow-y: auto;
- border: 1px solid rgba(102, 126, 234, 0.15);
- animation: cardFadeIn 0.6s ease-out;
-}
-
-@keyframes cardFadeIn {
- from { opacity: 0; transform: translateY(20px); }
- to { opacity: 1; transform: translateY(0); }
-}
-
-.knowledge-card::-webkit-scrollbar {
- width: 10px;
-}
-
-.knowledge-card::-webkit-scrollbar-track {
- background: #f1f3f9;
- border-radius: 10px;
-}
-
-.knowledge-card::-webkit-scrollbar-thumb {
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- border-radius: 10px;
- border: 2px solid #f1f3f9;
-}
-
-.knowledge-card::-webkit-scrollbar-thumb:hover {
- background: linear-gradient(135deg, #5568d3 0%, #65408b 100%);
-}
-
-.knowledge-card h3 {
- color: #667eea;
- font-size: 1.5em;
- font-weight: 700;
- margin-top: 0;
- margin-bottom: 20px;
- padding-bottom: 12px;
- border-bottom: 3px solid;
- border-image: linear-gradient(90deg, #667eea, #764ba2) 1;
-}
-
-/* 知识卡片标题中的 emoji 保持彩色 */
-.knowledge-card h3::first-letter {
- color: inherit;
-}
-
-.knowledge-card h4 {
- color: #4a5568;
- font-size: 1.2em;
- font-weight: 600;
- margin-top: 20px;
- margin-bottom: 12px;
-}
-
-.knowledge-card details {
- margin: 15px 0;
- border-radius: 8px;
- overflow: hidden;
-}
-
-.knowledge-card details summary {
- cursor: pointer;
- padding: 12px 16px;
- font-weight: 700;
- border-radius: 8px;
- transition: all 0.3s ease;
- user-select: none;
- color: #333;
- font-size: 1.05em;
-}
-
-.knowledge-card details summary:hover {
- transform: translateX(5px);
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
- filter: brightness(0.98);
-}
-
-.knowledge-card details[open] summary {
- margin-bottom: 10px;
- border-radius: 8px 8px 0 0;
-}
-
-.knowledge-card table {
- border-collapse: collapse;
- width: 100%;
- margin: 15px 0;
- font-size: 0.95em;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
- border-radius: 8px;
- overflow: hidden;
-}
-
-.knowledge-card table th {
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- color: white;
- padding: 12px;
- font-weight: 700;
-}
-
-.knowledge-card table td {
- padding: 10px 12px;
- border: 1px solid #e0e7ff;
-}
-
-.knowledge-card table tr:hover {
- background: #f8f9ff;
- transition: background 0.2s ease;
-}
-
-/* ==================== 聊天机器人样式 ==================== */
-.chatbot {
- border-radius: 16px !important;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08) !important;
- border: 1px solid rgba(102, 126, 234, 0.1) !important;
-}
-
-/* 用户消息气泡 - 紫色渐变 + 白色文字 */
-.message.user {
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
- color: white !important;
- border-radius: 18px 18px 4px 18px !important;
- padding: 12px 18px !important;
- box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3) !important;
-}
-
-/* 确保用户消息内的所有文字都是白色 */
-.message.user * {
- color: white !important;
-}
-
-/* 机器人消息气泡 - 白色背景 + 深色文字 */
-.message.bot {
- background: white !important;
- border: 1px solid #e0e7ff !important;
- border-radius: 18px 18px 18px 4px !important;
- padding: 12px 18px !important;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
- color: #2d3748 !important;
-}
-
-/* 确保机器人消息内的文字清晰可见 */
-.message.bot * {
- color: #2d3748 !important;
-}
-
-.message.bot p {
- color: #2d3748 !important;
- line-height: 1.6 !important;
- margin: 8px 0 !important;
-}
-
-.message.bot strong {
- color: #1a202c !important;
- font-weight: 700 !important;
-}
-
-.message.bot code {
- background: #f7fafc !important;
- color: #667eea !important;
- padding: 2px 6px !important;
- border-radius: 4px !important;
- font-family: 'Consolas', 'Monaco', monospace !important;
-}
-
-.message.bot pre {
- background: #f7fafc !important;
- border: 1px solid #e2e8f0 !important;
- border-radius: 6px !important;
- padding: 12px !important;
- overflow-x: auto !important;
-}
-
-.message.bot pre code {
- background: transparent !important;
- padding: 0 !important;
-}
-
-/* ==================== 性能指标文本框 ==================== */
-.gr-textbox.output-metrics {
- font-family: 'Consolas', 'Monaco', monospace !important;
- background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%) !important;
- border: 2px solid #e0e7ff !important;
- border-radius: 12px !important;
- padding: 16px !important;
- font-size: 0.95em !important;
- line-height: 1.8 !important;
-}
-
-/* ==================== 数字显示框 ==================== */
-.gr-number, .gain-display {
- font-size: 1.3em !important;
- font-weight: 700 !important;
- color: #667eea !important;
- text-align: center !important;
- background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%) !important;
- border: 2px solid #667eea !important;
- border-radius: 12px !important;
- padding: 16px !important;
-}
-
-.gain-display input {
- text-align: center !important;
- font-size: 1.5em !important;
- font-weight: 800 !important;
- color: #667eea !important;
-}
-
-/* ==================== 输出显示框 ==================== */
-.output-display {
- background: linear-gradient(135deg, #ffffff 0%, #f8f9ff 100%) !important;
- padding: 20px !important;
- border-radius: 12px !important;
- border: 2px solid #e0e7ff !important;
- min-height: 80px !important;
-}
-
-.stability-result {
- padding: 15px !important;
- border-radius: 10px !important;
- text-align: center !important;
- font-size: 1.2em !important;
- font-weight: 700 !important;
- margin-top: 10px !important;
-}
-
-/* ==================== 现代聊天机器人样式 ==================== */
-.modern-chatbot {
- background: white !important;
- border-radius: 16px !important;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08) !important;
-}
-
-/* ==================== Markdown 样式增强 ==================== */
-.gr-markdown {
- line-height: 1.8 !important;
-}
-
-.gr-markdown h1, .gr-markdown h2, .gr-markdown h3 {
- font-weight: 700 !important;
- margin-top: 1.5em !important;
- margin-bottom: 0.8em !important;
-}
-
-.gr-markdown code {
- background: #f0f4ff !important;
- padding: 2px 8px !important;
- border-radius: 4px !important;
- font-family: 'Consolas', 'Monaco', monospace !important;
- color: #667eea !important;
- border: 1px solid #e0e7ff !important;
-}
-
-.gr-markdown pre {
- background: #f8f9ff !important;
- border: 2px solid #e0e7ff !important;
- border-radius: 8px !important;
- padding: 16px !important;
-}
-
-/* ==================== 加载动画 ==================== */
-@keyframes pulse {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.6; }
-}
-
-.loading {
- animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
-}
-
-/* ==================== 响应式设计 ==================== */
-@media (max-width: 768px) {
- .main-title {
- font-size: 2em !important;
- }
-
- .subtitle {
- font-size: 1em !important;
- }
-
- .knowledge-card {
- padding: 16px;
- max-height: 400px;
- }
-}
-
-/* ==================== 暗色主题支持 ==================== */
-@media (prefers-color-scheme: dark) {
- .gradio-container {
- background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%) !important;
- }
-
- .gr-group {
- background: #0f3460 !important;
- border-color: rgba(102, 126, 234, 0.3) !important;
- }
-
- .knowledge-card {
- background: linear-gradient(135deg, #0f3460 0%, #1a1a2e 100%);
- border-color: rgba(102, 126, 234, 0.3);
- }
-
- .knowledge-card h3, .knowledge-card h4 {
- color: #a0aec0;
- }
-}
-
-/* ==================== 特殊效果 ==================== */
-.shimmer {
- background: linear-gradient(90deg,
- rgba(255,255,255,0) 0%,
- rgba(255,255,255,0.3) 50%,
- rgba(255,255,255,0) 100%);
- animation: shimmer 2s infinite;
-}
-
-@keyframes shimmer {
- 0% { transform: translateX(-100%); }
- 100% { transform: translateX(100%); }
-}
-"""
-
+from functools import partial
+
+# 从各个模块导入所需的功能
+import config
+from analysis_functions import (
+ display_transfer_function,
+ time_domain_analysis,
+ frequency_domain_analysis,
+ root_locus_analysis
+)
+from chatbot import chat_with_ai
+from user_stats import get_online_status_html, update_user_activity
+from ui_components import (
+ create_header,
+ create_time_domain_tab,
+ create_frequency_domain_tab,
+ create_root_locus_tab,
+ create_chatbot_tab
+)
+
+# 加载外部CSS文件
+with open("assets/styles.css", "r", encoding="utf-8") as f:
+ custom_css = f.read()
+
+# --- 主应用界面 ---
with gr.Blocks(title="自动控制理论学习网站 - AI+数智平台", css=custom_css) as demo:
+ # 1. 创建UI组件
# 用户会话ID(隐藏组件)
session_id = gr.State(value=lambda: str(time.time()) + "_" + str(hash(time.time())))
+
+ # 创建头部信息和在线计数器
+ online_counter = create_header()
- # 主标题 - 带动画效果
- gr.HTML(" 自动控制理论AI+数智平台
")
- gr.HTML("✨ 交互式控制系统分析与设计工具 | 时域·频域·根轨迹·AI问答 ✨
")
-
- # 在线人数显示
- online_counter = gr.HTML(value=get_online_status_html(), elem_id="online-counter")
-
- # 定时器:每10秒触发一次更新(隐藏组件)
- timer = gr.Timer(value=10, active=True)
-
- # 项目信息横幅 - 优化对比度和可读性
- gr.HTML("""
-
-
-
-
- 🎓
- 西北工业大学 Northwestern Polytechnical University
- |
- 2025年校级本科生建设项目资助
-
-
- """)
-
+ # 创建共享的输入组件
+ with gr.Row():
+ with gr.Column(scale=1):
+ with gr.Group():
+ gr.HTML("📊 通用系统参数
")
+ num_input = gr.Textbox(
+ label="传递函数分子系数 (Numerator)",
+ value="1",
+ placeholder="例如: 1 或 1,2,3",
+ info="💡 用逗号分隔多个系数,从最高次项到常数项"
+ )
+ den_input = gr.Textbox(
+ label="传递函数分母系数 (Denominator)",
+ value="1,6,11,6",
+ placeholder="例如: 1,2,1",
+ info="💡 分母阶数通常高于或等于分子阶数"
+ )
+
+ # 创建功能选项卡
with gr.Tabs() as tabs:
with gr.TabItem("⏱️ 时域分析 (Time Domain)", id=0):
- gr.HTML("""
-
-
- 💡 快速开始:输入传递函数的分子和分母系数(逗号分隔),点击"开始分析"查看系统响应特性
-
-
- """)
- with gr.Row():
- with gr.Column(scale=1):
- with gr.Group():
- gr.HTML("📊 输入系统参数
")
- num_input = gr.Textbox(
- label="传递函数分子系数 (Numerator)",
- value="1",
- placeholder="例如: 1 或 1,2,3",
- info="💡 用逗号分隔多个系数,从最高次项到常数项"
- )
- den_input = gr.Textbox(
- label="传递函数分母系数 (Denominator)",
- value="1,6,11,6",
- placeholder="例如: 1,2,1",
- info="💡 分母阶数通常高于或等于分子阶数"
- )
- with gr.Row():
- confirm_button = gr.Button(
- "✓ 显示传递函数",
- variant="secondary",
- scale=1,
- size="lg"
- )
- analyze_button = gr.Button(
- "🚀 开始分析",
- variant="primary",
- scale=1,
- elem_classes="primary-btn",
- size="lg"
- )
-
- with gr.Group():
- gr.HTML("🔧 系统模型
")
- tf_display = gr.Markdown(label="当前传递函数", elem_classes="output-display")
-
- with gr.Group():
- gr.HTML("📈 动态性能指标
")
- output_metrics = gr.Textbox(
- label="Performance Metrics",
- lines=8,
- interactive=False,
- elem_classes="output-metrics"
- )
-
- with gr.Column(scale=2):
- output_plot = gr.Plot(label="Time Response Curves", elem_classes="plot-container")
-
- # 使用 HTML 创建可滚动的知识卡片
- gr.HTML("""
-
-
📚 常用公式 (Common Formulas)
-
-
-
- 📐 二阶系统标准形式
-
-
-
对于一个典型的二阶系统,其标准传递函数形式为:
-
G(s) = ωn² / (s² + 2ζωns + ωn²)
-
-
参数说明
-
- - ζ (zeta) - 阻尼比 (Damping Ratio)
-
- - ζ < 0: 不稳定系统
- - ζ = 0: 无阻尼振荡(临界情况)
- - 0 < ζ < 1: 欠阻尼(有振荡)⭐ 最常见
- - ζ = 1: 临界阻尼(无振荡)
- - ζ > 1: 过阻尼(无振荡,响应慢)
-
-
- - ωn (omega_n) - 无阻尼自然频率
-
- - 单位:rad/s
- - 表示系统的固有振荡频率
- - 值越大,系统响应越快
-
-
-
-
-
-
-
-
- 📊 时域性能指标(欠阻尼系统,0 < ζ < 1)
-
-
-
-
-
1️⃣ 上升时间 (Rise Time, tr)
-
响应从终值的10%上升到90%所需的时间
-
📐 近似公式:tr ≈ 1.8 / ωn
-
-
-
-
2️⃣ 峰值时间 (Peak Time, tp)
-
响应达到第一个峰值所需的时间
-
📐 公式:tp = π / (ωn√(1-ζ²)) = π / ωd
-
其中 ωd = ωn√(1-ζ²) 是阻尼振荡频率
-
-
-
-
3️⃣ 超调量 (Percent Overshoot, σ%)
-
响应超过稳态值的最大百分比
-
📐 公式:σ% = e(-πζ/√(1-ζ²)) × 100%
-
仅与阻尼比 ζ 有关
-
💡 常见值:
-
• ζ = 0.5 时,σ% ≈ 16%
-
• ζ = 0.707 时,σ% ≈ 4.3%
-
-
-
-
-
4️⃣ 调节时间 (Settling Time, ts)
-
响应达到并保持在稳态值 ±2%(或±5%)范围内所需的时间
-
📐 公式:
-
• 2%误差带:ts ≈ 4 / (ζωn)
-
• 5%误差带:ts ≈ 3 / (ζωn)
-
-
主要由 ζωn 决定(系统时间常数)
-
-
-
-
5️⃣ 稳态误差 (Steady-State Error, ess)
-
不同输入下的稳态误差:
-
• 单位阶跃输入:ess = 1/(1+Kp)
-
• 单位斜坡输入:ess = 1/Kv
-
• 单位抛物线输入:ess = 1/Ka
-
其中 Kp, Kv, Ka 分别为位置、速度、加速度误差常数
-
-
-
-
-
-
- 🎯 一阶系统特性
-
-
-
标准传递函数:
-
G(s) = K / (τs + 1)
-
-
参数说明:
-
- - K - 系统增益(稳态增益)
- - τ (tau) - 时间常数,单位:秒
-
-
-
阶跃响应:
-
y(t) = K(1 - e-t/τ)
-
-
-
💡 关键时间点:
-
- - t = τ 时,响应达到稳态值的 63.2%
- - t = 3τ 时,响应达到稳态值的 95%
- - t = 4τ 时,响应达到稳态值的 98.2%
- - t = 5τ 时,响应达到稳态值的 99.3%
-
-
-
-
特点:一阶系统无超调,响应单调上升
-
-
-
-
-
- 🔄 系统类型与误差系数
-
-
-
系统类型分类:
-
根据开环传递函数原点处的极点数(积分环节数)v 分类:
-
-
-
- | 类型 |
- Kp |
- Kv |
- Ka |
-
-
- | 0 型 |
- 有限值 |
- 0 |
- 0 |
-
-
- | I 型 |
- ∞ |
- 有限值 |
- 0 |
-
-
- | II 型 |
- ∞ |
- ∞ |
- 有限值 |
-
-
-
-
稳态误差计算:
-
- - 阶跃输入 r(t) = R:ess = R/(1+Kp)
- - 斜坡输入 r(t) = Rt:ess = R/Kv
- - 抛物线输入 r(t) = Rt²/2:ess = R/Ka
-
-
-
-
⚡ 工程结论:
-
系统型别越高,跟踪能力越强,但稳定性可能降低。实际系统常用 I 型或 II 型。
-
-
-
-
-
-
- ⚙️ 主导极点与零点影响
-
-
-
主导极点概念:
-
距虚轴最近的极点对系统动态响应起主导作用,其他极点影响较小。
-
-
判断条件:
-
- - 主导极点与其他极点的实部相差 5倍以上
- - 主导极点与零点距离较远(相差3倍以上)
-
-
-
零点的影响:
-
-
🔺 左半平面零点(最小相位系统):
-
- - 增加超调量
- - 减小上升时间
- - 零点越靠近极点,影响越大
-
-
-
-
-
🔻 右半平面零点(非最小相位系统):
-
- - 引起反向响应(初始反向运动)
- - 增加调节时间
- - 降低系统性能
-
-
-
-
-
- """)
-
+ time_domain_ui = create_time_domain_tab()
with gr.TabItem("📊 频域分析 (Frequency Domain)", id=1):
- gr.HTML("""
-
-
- 💡 使用技巧:拖动增益滑块查看系统稳定性变化,观察Bode图和Nyquist图的实时更新
-
-
- """)
- with gr.Row():
- with gr.Column(scale=1):
- with gr.Group():
- gr.HTML("🎚️ 调整系统增益
")
- log_k_slider_freq = gr.Slider(
- minimum=-4,
- maximum=4,
- value=1,
- step=0.01,
- label="对数增益 log₁₀(K)",
- info="💡 滑块范围:10⁻⁴ ~ 10⁴"
- )
- k_number_display_freq = gr.Number(
- value=10.0,
- label="当前增益 K (Gain)",
- interactive=False,
- elem_classes="gain-display"
- )
-
- with gr.Group():
- gr.HTML("🔧 当前系统模型
")
- freq_tf_display = gr.Markdown(label="含增益K的传递函数", elem_classes="output-display")
-
- with gr.Group():
- gr.HTML("📊 稳定裕度分析
")
- freq_metrics_display = gr.Textbox(
- label="Stability Margins",
- lines=4,
- interactive=False,
- elem_classes="output-metrics"
- )
- freq_stability_display = gr.Markdown(elem_classes="stability-result")
-
- with gr.Column(scale=2):
- freq_plot_output = gr.Plot(label="Frequency Response Plots", elem_classes="plot-container")
-
- # 频域分析知识卡片
- gr.HTML("""
-
-
📚 常用定义 (Common Definitions)
-
-
- 📈 频域分析基础
-
-
频域分析通过研究系统对不同频率正弦信号的响应特性来评估系统性能。
-
- - Bode图:幅频和相频特性
- - Nyquist图:极坐标表示
- - 稳定裕度:系统稳定性余量
-
-
-
-
-
- 🎯 增益裕度 (Gain Margin, GM)
-
-
定义: 在相角为-180°时,系统增益可以增加的最大倍数(或dB数),而不会使系统变得不稳定。
-
- GMdB = -20 log10 |G(jωpc)|
-
-
-
关键概念
-
ωpc (相角交越频率):系统相角等于-180°时的频率
-
-
判断准则
-
- | GM > 0 dB | ✅ 系统稳定 |
- | GM = 0 dB | ⚠️ 临界稳定 |
- | GM < 0 dB | ❌ 系统不稳定 |
-
-
-
- 💡 工程要求: 通常要求 GM ≥ 6 dB (约2倍),提供对增益变化的鲁棒性
-
-
-
-
-
- 🎯 相角裕度 (Phase Margin, PM)
-
-
定义: 在增益为1(0dB)时,系统相角与-180°之间的差值。
-
- PM = 180° + ∠G(jωgc)
-
-
-
关键概念
-
ωgc (增益交越频率):系统幅值等于1(0dB)时的频率
-
-
判断准则
-
- | PM > 0° | ✅ 系统稳定 |
- | PM = 0° | ⚠️ 临界稳定 |
- | PM < 0° | ❌ 系统不稳定 |
-
-
-
- 💡 工程要求: 通常要求 PM ≥ 30° ~ 60°
- • PM ≈ 45° ~ 60° : 良好的阻尼特性
- • PM 越大,系统超调量越小
-
-
-
与时域性能的关系
-
对于二阶系统:ζ ≈ PM/100 (PM以度为单位)
-
- | PM | ζ | 超调量 |
- | 30° | ≈ 0.3 | ≈ 37% |
- | 45° | ≈ 0.45 | ≈ 20% |
- | 60° | ≈ 0.6 | ≈ 10% |
-
-
-
-
-
- 📊 Bode图与Nyquist图
-
-
Bode图(伯德图)
-
组成:幅频特性图 + 相频特性图
-
- - 横轴:频率 ω (对数刻度)
- - 纵轴(幅频):幅值 20log|G(jω)| (dB)
- - 纵轴(相频):相角 ∠G(jω) (度)
-
-
-
-
💡 优点:
-
- - 便于绘制(用渐近线近似)
- - 直观读取稳定裕度
- - 便于串联系统分析(图形叠加)
-
-
-
-
Nyquist图(奈奎斯特图)
-
定义:开环频率特性 G(jω)H(jω) 在复平面上的轨迹图
-
- - 横轴:实部 Re[G(jω)]
- - 纵轴:虚部 Im[G(jω)]
-
-
-
-
🎯 奈奎斯特稳定判据:
-
闭环系统稳定的充要条件:当 ω 从 -∞ 变化到 +∞ 时,Nyquist曲线逆时针包围(-1, j0)点的圈数 N 等于开环系统右半平面极点数 P。
-
Z = P - N
-
其中 Z 为闭环系统右半平面极点数
-
-
-
带宽 (Bandwidth, BW)
-
闭环频率响应的幅值下降到-3dB时的频率。BW 越大,系统响应越快。
-
-
谐振峰值 (Resonant Peak, Mr)
-
闭环频率响应的最大幅值。Mr 越小,系统阻尼越好。通常要求 Mr < 1.3 ~ 1.5。
-
-
-
-
- 🔧 典型环节的频率特性
-
-
1️⃣ 比例环节 K
-
• 幅频:20logK (dB) - 水平直线
- • 相频:0° - 水平直线
-
-
2️⃣ 积分环节 1/s
-
• 幅频:-20dB/dec 斜率的直线
- • 相频:-90° - 水平直线
-
-
3️⃣ 微分环节 s
-
• 幅频:+20dB/dec 斜率的直线
- • 相频:+90° - 水平直线
-
-
4️⃣ 惯性环节 1/(Ts+1)
-
• 转折频率:ω = 1/T
- • 幅频:低频0dB,高频-20dB/dec
- • 相频:从0°下降到-90°
-
-
5️⃣ 一阶微分环节 Ts+1
-
• 转折频率:ω = 1/T
- • 幅频:低频0dB,高频+20dB/dec
- • 相频:从0°上升到+90°
-
-
6️⃣ 二阶振荡环节 ωn²/(s²+2ζωns+ωn²)
-
• 转折频率:ω = ωn
- • 幅频:低频0dB,高频-40dB/dec
- • 相频:从0°下降到-180°
- • 谐振峰值与阻尼比 ζ 相关
-
-
-
-
- ⚡ 最小相位系统与全通系统
-
-
最小相位系统
-
定义:传递函数的所有零点和极点都在左半s平面的系统。
-
-
-
✅ 特点:
-
- - 幅频特性与相频特性一一对应
- - 可由幅频特性唯一确定相频特性
- - 相角滞后最小(同样幅频特性下)
-
-
-
-
非最小相位系统
-
定义:传递函数在右半s平面有零点或极点的系统。
-
-
-
⚠️ 特点:
-
- - 相角滞后较大
- - 幅频特性相同,相频特性不同
- - 稳定性和动态性能较差
-
-
-
-
全通系统
-
特点:幅频特性为常数(|G(jω)| = 1),只改变相频特性。
-
应用:相位校正、延时补偿
-
-
-
- """)
-
+ freq_domain_ui = create_frequency_domain_tab()
with gr.TabItem("🎯 根轨迹 (Root Locus)", id=2):
- gr.HTML("""
-
-
- 💡 分析要点:观察极点在s平面的运动轨迹,左半平面的极点表示系统稳定
-
-
- """)
- with gr.Row():
- with gr.Column(scale=1):
- with gr.Group():
- gr.HTML("🎚️ 调整系统增益
")
- log_k_slider_rl = gr.Slider(
- minimum=-4,
- maximum=4,
- value=1,
- step=0.01,
- label="对数增益 log₁₀(K)",
- info="💡 拖动滑块观察极点移动"
- )
- k_number_display = gr.Number(
- value=10.0,
- label="当前增益 K (Gain)",
- interactive=False,
- elem_classes="gain-display"
- )
-
- with gr.Group():
- gr.HTML("📍 闭环极点位置
")
- rl_poles_display = gr.Textbox(
- label="Closed-Loop Pole Locations",
- lines=6,
- interactive=False,
- elem_classes="output-metrics"
- )
- with gr.Column(scale=2):
- rl_plot_output = gr.Plot(label="Root Locus Plot")
- rl_formula_text = """
-
-
📚 根轨迹知识要点 (Root Locus Essentials)
-
-
-
- 🎯 根轨迹法简介
-
-
-
定义: 根轨迹是当开环系统增益 K 从 0 变化到 ∞ 时,闭环系统特征方程的根(极点)在 s 平面上描绘出的轨迹。
-
-
主要作用:
-
- - ✅ 直观显示参数变化对系统极点位置的影响
- - ✅ 判断系统稳定性
- - ✅ 选择合适的增益值
- - ✅ 设计控制器参数
-
-
-
-
-
-
- 🔧 闭环系统与特征方程
-
-
-
对于单位负反馈系统,闭环传递函数为:
-
- T(s) = KG(s) / [1 + KG(s)H(s)]
-
-
-
特征方程:
-
- 1 + K G(s)H(s) = 0 或 K G(s)H(s) = -1
-
-
-
-
- | 符号 |
- 含义 |
-
-
- | G(s) |
- 前向通道传递函数 |
-
-
- | H(s) |
- 反馈通道传递函数(单位反馈时 H(s)=1) |
-
-
- | K |
- 可变增益参数 |
-
-
- | 特征方程的根 |
- 闭环极点 |
-
-
-
-
-
-
-
- 📐 根轨迹绘制的基本条件
-
-
-
设开环传递函数为:
-
- G(s)H(s) = K(s-z1)(s-z2)···(s-zm) / [(s-p1)(s-p2)···(s-pn)]
-
-
-
根轨迹上的点 s0 必须满足:
-
-
-
1️⃣ 幅值条件(充要条件)
-
|K G(s0)H(s0)| = 1
-
💡 物理意义: 确定增益 K 的值,使得 s0 成为闭环极点。
-
-
-
-
2️⃣ 相角条件(充要条件)
-
∠G(s0)H(s0) = (2k+1)180°
-
其中 k = 0, ±1, ±2, ±3, ...
-
💡 物理意义: 判断 s 平面上某点是否在根轨迹上。
-
-
角度计算公式:
-
∠G(s0)H(s0) = Σ∠(s0-zi) - Σ∠(s0-pj)
-
- - 从所有零点到 s0 的角度之和
- - 减去从所有极点到 s0 的角度之和
-
-
-
-
-
-
-
- 🌟 根轨迹的基本性质
-
-
-
-
-
1️⃣ 起点和终点
-
- - 🟢 起点 (K=0):开环极点 pj
- - 🔴 终点 (K→∞):开环零点 zi 或无穷远处
-
-
-
-
-
2️⃣ 根轨迹分支数
-
- - 分支数 = max(n, m),其中 n=极点数,m=零点数
- - 当 n > m 时,有 (n-m) 条分支趋向无穷远
-
-
-
-
-
3️⃣ 实轴上的根轨迹
-
实轴上某区段,若其右侧的实数开环零点和极点总数为奇数,则该区段在根轨迹上。
-
-
-
-
4️⃣ 渐近线
-
当 n > m 时,有 (n-m) 条分支沿渐近线趋向无穷远:
-
- 渐近线角度: φa = (2k+1)180° / (n-m)
-
-
k = 0, 1, 2, ..., (n-m-1)
-
-
- 渐近线交点(重心): σa = (Σpj - Σzi) / (n-m)
-
-
-
-
-
5️⃣ 分离点/会合点
-
- - 定义:多条根轨迹分支分离或会合的点
- - 求解条件:dK/ds = 0
-
-
-
-
-
-
-
- 🛡️ s 平面的稳定性区域
-
-
-
-
- | 区域 |
- 条件 |
- 稳定性 |
-
-
- | 左半平面 |
- Re(s) < 0 |
- ✅ 稳定 |
-
-
- | 虚轴 |
- Re(s) = 0 |
- ⚠️ 临界稳定 |
-
-
- | 右半平面 |
- Re(s) > 0 |
- ❌ 不稳定 |
-
-
-
-
-
稳定性判断准则:
-
- - ✅ 所有闭环极点都在左半平面 → 系统稳定
- - ❌ 有极点在右半平面 → 系统不稳定
- - ⚠️ 有极点在虚轴上 → 临界稳定
-
-
-
-
-
-
-
- 📏 阻尼比等值线
-
-
-
从原点出发的射线代表恒定阻尼比 ζ 的轨迹:
-
- θ = arccos(ζ)
-
-
-
-
- | 阻尼比 ζ |
- 角度 θ |
- 系统响应特性 |
-
-
- | 0.5 |
- 60° |
- 欠阻尼,有较大超调 |
-
-
- | 0.707 |
- 45° |
- 最佳阻尼,超调适中 |
-
-
-
-
-
💡 工程应用:
-
通过根轨迹与阻尼比等值线的交点,可以选择满足动态性能要求的增益 K 值。
-
-
-
-
-
-
- 🎨 根轨迹绘制规则总结
-
-
-
-
-
- | 规则 |
- 内容 |
-
-
- | 起点 |
- K=0,位于开环极点 |
-
-
- | 终点 |
- K→∞,位于开环零点或无穷远 |
-
-
- | 分支数 |
- 等于 max(n, m) |
-
-
- | 实轴段 |
- 右侧零极点总数为奇数的区段 |
-
-
- | 渐近线数 |
- n - m 条 |
-
-
- | 渐近线角度 |
- φa = (2k+1)·180°/(n-m) |
-
-
- | 渐近线交点 |
- σa = (Σp - Σz)/(n-m) |
-
-
- | 分离/会合点 |
- dK/ds = 0 的实根 |
-
-
- | 与虚轴交点 |
- 用劳斯判据或令 s=jω 求解 |
-
-
- | 出射角/入射角 |
- 复数极点/零点处的根轨迹切线角度 |
-
-
-
-
-
📝 绘制步骤:
-
- - 标出开环零极点
- - 确定实轴上的根轨迹
- - 计算渐近线(角度和交点)
- - 求分离点/会合点
- - 计算与虚轴交点
- - 求复数极点的出射角
- - 绘制完整根轨迹
-
-
-
-
-
-
-
- 🔍 零度根轨迹(180°根轨迹)
-
-
-
定义:当参数 K 从 0 变化到 -∞ 时,闭环极点的轨迹。
-
-
相角条件:
-
- ∠G(s0)H(s0) = 2k·180° (k = 0, ±1, ±2, ...)
-
-
-
-
⚠️ 与常规根轨迹的区别:
-
- - 实轴段:右侧零极点总数为偶数的区段
- - 渐近线角度:φa = 2k·180°/(n-m)
- - 图形表示:通常用虚线表示
-
-
-
-
应用场景:
-
- - 负反馈系统中的正增益变化
- - 正反馈系统分析
- - 参数补偿器设计
-
-
-
-
-
-
- ⚙️ 根轨迹与系统性能
-
-
-
稳定性分析:
-
-
✅ 稳定条件:所有闭环极点都在左半 s 平面
-
- - 根轨迹完全在左半平面 → 系统对所有 K>0 都稳定
- - 根轨迹穿越虚轴 → 存在临界增益 Kc
- - 根轨迹在右半平面 → 某些 K 值下系统不稳定
-
-
-
-
动态性能分析:
-
-
- | 极点位置 |
- 系统响应特性 |
-
-
- | 实轴负半轴 |
- 单调响应,无振荡 |
-
-
- | 左半平面共轭复数 |
- 衰减振荡,有超调 |
-
-
- | 虚轴上 |
- 等幅振荡,临界稳定 |
-
-
- | 右半平面 |
- 发散,不稳定 |
-
-
-
-
增益 K 的选择:
-
-
💡 设计原则:
-
- - 稳定性要求:K < Kc(临界增益)
- - 快速性要求:选择使主导极点实部较大的 K
- - 平稳性要求:选择使主导极点阻尼比 ζ ∈ [0.4, 0.8] 的 K
- - 精度要求:K 越大,稳态误差越小
-
-
-
-
性能折中:
-
- 增大 K 可以提高系统精度和快速性,但可能降低稳定性和增大超调。
- 需要在根轨迹上选择合适的工作点,平衡各项性能指标。
-
-
-
-
-
-
- 🛠️ 根轨迹法的应用
-
-
-
1️⃣ 系统分析:
-
- - 判断系统稳定性及稳定范围
- - 确定临界增益 Kc
- - 分析参数变化对系统性能的影响
- - 确定主导极点位置
-
-
-
2️⃣ 控制器设计:
-
- - 相位超前补偿:在根轨迹左侧增加零点,拉动根轨迹向左
- - 相位滞后补偿:在原点附近增加零极点对,提高低频增益
- - PID控制器:通过零极点配置实现性能要求
-
-
-
3️⃣ 极点配置:
-
-
通过添加合适的零极点,使闭环极点位于期望位置:
-
- - 满足阻尼比要求:ζ = cosθ(θ为极点角度)
- - 满足调节时间要求:σ = 4/(ζωn)
- - 满足超调量要求:σ% = e-πζ/√(1-ζ²)
-
-
-
-
4️⃣ 鲁棒性分析:
-
- 根轨迹可以直观显示参数不确定性对系统稳定性的影响,
- 帮助评估系统的鲁棒性能。
-
-
-
-
-
- """
- gr.HTML(rl_formula_text)
-
+ root_locus_ui = create_root_locus_tab()
with gr.TabItem("🤖 智能问答 (Q&A)", id=3):
- gr.HTML("""
-
-
- 💡 AI助手:基于DeepSeek大模型,可以回答控制理论相关问题,支持LaTeX公式渲染
-
-
- """)
-
- with gr.Row():
- with gr.Column(scale=1):
- chatbot = gr.Chatbot(
- label="🎓 自控原理AI助教",
- type="messages",
- avatar_images=(
- "https://img.icons8.com/fluency/96/user-male-circle.png",
- "https://img.icons8.com/fluency/96/chatbot.png"
- ),
- height=650,
- latex_delimiters=[
- {"left": "$$", "right": "$$", "display": True},
- {"left": "$", "right": "$", "display": False},
- {"left": "\\[", "right": "\\]", "display": True},
- {"left": "\\(", "right": "\\)", "display": False}
- ],
- elem_classes="modern-chatbot"
- )
-
- with gr.Row():
- chat_input = gr.Textbox(
- label="",
- placeholder="💬 输入您的问题,例如:什么是PID控制器?如何分析系统稳定性?",
- scale=4,
- lines=2,
- max_lines=4
- )
- with gr.Column(scale=1, min_width=120):
- send_button = gr.Button(
- "📤 发送",
- variant="primary",
- size="lg",
- elem_classes="primary-btn"
- )
- clear_button = gr.Button(
- "🗑️ 清空",
- variant="secondary",
- size="lg"
- )
-
- # 示例问题
- gr.HTML("""
-
-
- 💡 试试这些问题:
-
-
-
- 什么是传递函数?
-
-
- 如何判断系统稳定性?
-
-
- 解释Bode图的物理意义
-
-
- PID控制器各参数的作用
-
-
-
- """)
-
+ chatbot_ui = create_chatbot_tab()
- # --- 事件绑定部分 ---
- # 包装函数:更新在线人数
- def update_online_counter_wrapper(func):
- def wrapper(*args, **kwargs):
- result = func(*args, **kwargs)
- # 如果返回的是元组,添加在线人数HTML
- if isinstance(result, tuple):
- return result + (get_online_status_html(),)
- return result, get_online_status_html()
- return wrapper
-
- confirm_button.click(
- fn=display_transfer_function,
- inputs=[num_input, den_input, session_id],
- outputs=[tf_display, online_counter]
- )
-
- analyze_button.click(
- fn=time_domain_analysis,
- inputs=[num_input, den_input, session_id],
- outputs=[output_plot, output_metrics, online_counter]
- )
-
- def update_frequency_analysis(num, den, log_k, sid):
- k = 10**log_k
- fig, metrics, tf_latex, stability = frequency_domain_analysis(num, den, k, sid)
- return fig, metrics, tf_latex, stability, k, get_online_status_html()
-
- log_k_slider_freq.release(
- fn=update_frequency_analysis,
- inputs=[num_input, den_input, log_k_slider_freq, session_id],
- outputs=[freq_plot_output, freq_metrics_display, freq_tf_display, freq_stability_display, k_number_display_freq, online_counter]
- )
-
- num_input.change(
- fn=update_frequency_analysis,
- inputs=[num_input, den_input, log_k_slider_freq, session_id],
- outputs=[freq_plot_output, freq_metrics_display, freq_tf_display, freq_stability_display, k_number_display_freq, online_counter]
- )
-
- den_input.change(
- fn=update_frequency_analysis,
- inputs=[num_input, den_input, log_k_slider_freq, session_id],
- outputs=[freq_plot_output, freq_metrics_display, freq_tf_display, freq_stability_display, k_number_display_freq, online_counter]
- )
-
- def update_rl_view(log_k, num, den, sid):
- fig, poles, k_val = root_locus_analysis(num, den, log_k, sid)
- return fig, poles, k_val, get_online_status_html()
-
- log_k_slider_rl.release(
- fn=update_rl_view,
- inputs=[log_k_slider_rl, num_input, den_input, session_id],
- outputs=[rl_plot_output, rl_poles_display, k_number_display, online_counter]
- )
-
- num_input.change(
- fn=update_rl_view,
- inputs=[log_k_slider_rl, num_input, den_input, session_id],
- outputs=[rl_plot_output, rl_poles_display, k_number_display, online_counter]
- )
-
- den_input.change(
- fn=update_rl_view,
- inputs=[log_k_slider_rl, num_input, den_input, session_id],
- outputs=[rl_plot_output, rl_poles_display, k_number_display, online_counter]
- )
-
- # 聊天机器人事件处理 - 支持 DeepSeek 和 Gemini
- # 按钮点击事件
- send_button.click(
- fn=chat_with_ai,
- inputs=[chat_input, chatbot, session_id],
- outputs=chatbot,
- ).then(
- lambda: ("", get_online_status_html()), # 清空输入框并更新在线人数
- outputs=[chat_input, online_counter]
- )
-
- # 输入框回车事件
- chat_input.submit(
- fn=chat_with_ai,
- inputs=[chat_input, chatbot, session_id],
- outputs=chatbot,
- ).then(
- lambda: ("", get_online_status_html()), # 清空输入框并更新在线人数
- outputs=[chat_input, online_counter]
- )
-
- # 清空聊天记录
- clear_button.click(
- lambda sid: ([], get_online_status_html()),
- inputs=[session_id],
- outputs=[chatbot, online_counter]
- )
-
- def on_tab_select(evt: gr.SelectData, num, den, log_k_freq, log_k_rl, sid):
+ # 2. 绑定事件逻辑
+ # --- 通用函数 ---
+ # 每次操作前更新用户活跃状态
+ def wrap_with_activity_update(fn, sid):
update_user_activity(sid)
- outputs = {
- freq_plot_output: gr.update(),
- freq_metrics_display: gr.update(),
- freq_tf_display: gr.update(),
- freq_stability_display: gr.update(),
- k_number_display_freq: gr.update(),
- rl_plot_output: gr.update(),
- rl_poles_display: gr.update(),
- k_number_display: gr.update(),
- online_counter: get_online_status_html()
- }
- if evt.index == 1:
- k_freq = 10**log_k_freq
- fig, metrics, tf_latex, stability = frequency_domain_analysis(num, den, k_freq, sid)
- outputs[freq_plot_output], outputs[freq_metrics_display], outputs[freq_tf_display], outputs[freq_stability_display], outputs[k_number_display_freq] = fig, metrics, tf_latex, stability, k_freq
- elif evt.index == 2:
- fig, poles, k_val = root_locus_analysis(num, den, log_k_rl, sid)
- outputs[rl_plot_output], outputs[rl_poles_display], outputs[k_number_display] = fig, poles, k_val
- return outputs
+ # 使用 partial 将 session_id 绑定到函数上
+ # 这样Gradio调用时就不需要显式传递session_id了
+ return partial(fn, session_id=sid)
- tabs.select(
- on_tab_select,
- inputs=[num_input, den_input, log_k_slider_freq, log_k_slider_rl, session_id],
- outputs=[freq_plot_output, freq_metrics_display, freq_tf_display, freq_stability_display, k_number_display_freq, rl_plot_output, rl_poles_display, k_number_display, online_counter]
+ # --- 时域分析事件 ---
+ time_domain_ui["confirm_button"].click(
+ fn=display_transfer_function,
+ inputs=[num_input, den_input],
+ outputs=[time_domain_ui["tf_display"]]
+ ).then(lambda: get_online_status_html(), outputs=online_counter)
+
+ time_domain_ui["analyze_button"].click(
+ fn=time_domain_analysis,
+ inputs=[num_input, den_input],
+ outputs=[time_domain_ui["output_plot"], time_domain_ui["output_metrics"]]
+ ).then(lambda: get_online_status_html(), outputs=online_counter)
+
+ # --- 频域分析事件 ---
+ def update_frequency_analysis_wrapper(num, den, log_k):
+ k = 10**log_k
+ fig, metrics, tf_latex, stability = frequency_domain_analysis(num, den, k)
+ return fig, metrics, tf_latex, stability, k, get_online_status_html()
+
+ freq_inputs = [num_input, den_input, freq_domain_ui["log_k_slider"]]
+ freq_outputs = [
+ freq_domain_ui["plot_output"],
+ freq_domain_ui["metrics_display"],
+ freq_domain_ui["tf_display"],
+ freq_domain_ui["stability_display"],
+ freq_domain_ui["k_number_display"],
+ online_counter
+ ]
+ freq_domain_ui["log_k_slider"].release(
+ fn=update_frequency_analysis_wrapper,
+ inputs=freq_inputs,
+ outputs=freq_outputs
+ )
+
+ # --- 根轨迹分析事件 ---
+ def update_rl_view_wrapper(log_k, num, den):
+ fig, poles, k_val = root_locus_analysis(num, den, log_k)
+ return fig, poles, k_val, get_online_status_html()
+
+ rl_inputs = [root_locus_ui["log_k_slider"], num_input, den_input]
+ rl_outputs = [
+ root_locus_ui["plot_output"],
+ root_locus_ui["poles_display"],
+ root_locus_ui["k_number_display"],
+ online_counter
+ ]
+ root_locus_ui["log_k_slider"].release(
+ fn=update_rl_view_wrapper,
+ inputs=rl_inputs,
+ outputs=rl_outputs
)
- # 页面加载时:记录一次活跃,计入总人数,并初始化在线人数
- def _on_page_load(sid):
- try:
- update_user_activity(sid)
- finally:
- return (get_online_status_html(),)
+ # 当输入框变化时,也更新频域和根轨迹(如果它们是当前可见的)
+ def update_all_on_tf_change(num, den, log_k_freq, log_k_rl):
+ # 更新频域
+ k_freq = 10**log_k_freq
+ fig_freq, metrics, tf_latex, stability = frequency_domain_analysis(num, den, k_freq)
+
+ # 更新根轨迹
+ fig_rl, poles, k_val_rl = root_locus_analysis(num, den, log_k_rl)
- demo.load(
- fn=_on_page_load,
+ return (
+ fig_freq, metrics, tf_latex, stability, k_freq,
+ fig_rl, poles, k_val_rl,
+ get_online_status_html()
+ )
+
+ tf_change_inputs = [num_input, den_input, freq_domain_ui["log_k_slider"], root_locus_ui["log_k_slider"]]
+ tf_change_outputs = freq_outputs[:-1] + rl_outputs[:-1] + [online_counter]
+
+ num_input.change(fn=update_all_on_tf_change, inputs=tf_change_inputs, outputs=tf_change_outputs)
+ den_input.change(fn=update_all_on_tf_change, inputs=tf_change_inputs, outputs=tf_change_outputs)
+
+ # --- 聊天机器人事件 ---
+ async def chat_wrapper(message, history, sid):
+ update_user_activity(sid)
+ # chat_with_ai 是一个生成器,Gradio可以直接处理
+ async for response in chat_with_ai(message, history):
+ yield response
+
+ chatbot_ui["send_button"].click(
+ fn=chat_wrapper,
+ inputs=[chatbot_ui["chat_input"], chatbot_ui["chatbot"], session_id],
+ outputs=chatbot_ui["chatbot"]
+ ).then(lambda: ("", get_online_status_html()), outputs=[chatbot_ui["chat_input"], online_counter])
+
+ chatbot_ui["chat_input"].submit(
+ fn=chat_wrapper,
+ inputs=[chatbot_ui["chat_input"], chatbot_ui["chatbot"], session_id],
+ outputs=chatbot_ui["chatbot"]
+ ).then(lambda: ("", get_online_status_html()), outputs=[chatbot_ui["chat_input"], online_counter])
+
+ def clear_chat_wrapper(sid):
+ update_user_activity(sid)
+ return [], get_online_status_html()
+
+ chatbot_ui["clear_button"].click(
+ fn=clear_chat_wrapper,
inputs=[session_id],
- outputs=[online_counter]
+ outputs=[chatbot_ui["chatbot"], online_counter]
)
- # 定时器触发:每10秒更新一次在线人数
- timer.tick(
- fn=lambda sid: get_online_status_html(),
- inputs=[session_id],
- outputs=[online_counter]
- )
+ # --- 页面加载和定时器事件 ---
+ def on_page_load(sid):
+ update_user_activity(sid)
+ return get_online_status_html()
+
+ demo.load(fn=on_page_load, inputs=[session_id], outputs=[online_counter])
+
+ gr.Timer(10).tick(fn=get_online_status_html, outputs=online_counter)
+
if __name__ == "__main__":
- # 需要安装 aiohttp: pip install aiohttp
-
demo.queue().launch(
- server_name="0.0.0.0", # 监听所有网络接口
- server_port=7860, # 指定一个端口
- share=False # 关闭Gradio的临时分享
+ server_name=config.SERVER_NAME,
+ server_port=config.SERVER_PORT,
+ share=config.SHARE
)
diff --git a/assets/knowledge_cards_html.py b/assets/knowledge_cards_html.py
new file mode 100644
index 0000000..eba761e
--- /dev/null
+++ b/assets/knowledge_cards_html.py
@@ -0,0 +1,499 @@
+"""
+知识卡片内容 - 包含时域、频域、根轨迹分析的公式和方法
+使用纯HTML格式,无需LaTeX渲染库
+"""
+
+# 时域分析知识卡片
+TIME_DOMAIN_KNOWLEDGE = """
+
+
+ 📚 时域分析常用公式
+
+
+
+
+ 📐 二阶系统标准形式
+
+
+
标准传递函数:
+
+
+
G(
s) =
+
+
ωn2
+
s2 + 2ζωns + ωn2
+
+
+
+
+
📊 关键参数
+
+
+ | 参数 |
+ 含义 |
+ 影响 |
+
+
+ | ζ (zeta) |
+ 阻尼比 |
+ 控制超调量和振荡 |
+
+
+ | ωn |
+ 无阻尼自然频率 (rad/s) |
+ 决定响应速度 |
+
+
+
+
🎯 阻尼比分类
+
+ - ζ < 0: 不稳定系统
+ - ζ = 0: 无阻尼振荡
+ - 0 < ζ < 1: 欠阻尼(有振荡)⭐ 最常见
+ - ζ = 1: 临界阻尼(无超调)
+ - ζ > 1: 过阻尼(响应慢)
+
+
+
+
+
+
+ 📊 时域性能指标(欠阻尼系统)
+
+
+
+
+
1️⃣ 上升时间 (Rise Time, tr)
+
响应从 10% 上升到 90% 终值所需时间
+
+
+
+
+
2️⃣ 峰值时间 (Peak Time, tp)
+
响应达到第一个峰值的时间
+
+
其中 ωd = ωn√(1−ζ²) 为阻尼振荡频率
+
+
+
+
3️⃣ 超调量 (Overshoot, σ%)
+
响应超过稳态值的最大百分比
+
+ σ% = e−πζ/√(1−ζ²) × 100%
+
+
💡 仅与 ζ 有关!
+
+
+ | ζ = 0.5 |
+ σ% ≈ 16% |
+
+
+ | ζ = 0.707 |
+ σ% ≈ 4.3% ⭐ 最佳 |
+
+
+
+
+
+
4️⃣ 调节时间 (Settling Time, ts)
+
响应达到并保持在稳态值 ±2%(或±5%)范围内的时间
+
+
+
+
+
+
+
+ 🎯 一阶系统特性
+
+
+
标准传递函数:
+
+
+
阶跃响应:
+
+ y(t) = K(1 − e−t/τ)
+
+
+
+
💡 关键时间点:
+
+ - t = τ: 达到稳态值的 63.2%
+ - t = 3τ: 达到稳态值的 95%
+ - t = 4τ: 达到稳态值的 98.2%
+ - t = 5τ: 达到稳态值的 99.3%
+
+
+
+
+
+"""
+
+# 频域分析知识卡片
+FREQUENCY_DOMAIN_KNOWLEDGE = """
+
+
+ 📚 频域分析常用方法
+
+
+
+
+ 🎯 增益裕度 (Gain Margin, GM)
+
+
+
定义:在相角为 -180° 时,系统增益可增加的最大倍数
+
+
+ GMdB = −20log10|G(jωpc)|
+
+
+
ωpc - 相角交越频率(相角 = -180°)
+
+
判断准则
+
+
+ | GM > 0 dB |
+ ✅ 系统稳定 |
+
+
+ | GM = 0 dB |
+ ⚠️ 临界稳定 |
+
+
+ | GM < 0 dB |
+ ❌ 系统不稳定 |
+
+
+
+
+
💡 工程要求:通常要求 GM ≥ 6 dB(约2倍增益余量)
+
+
+
+
+
+
+ 🎯 相角裕度 (Phase Margin, PM)
+
+
+
定义:在增益为 1 (0dB) 时,系统相角与 -180° 的差值
+
+ PM = 180° + ∠G(jωgc)
+
+
ωgc - 增益交越频率(幅值 = 1 或 0dB)
+
+
判断准则
+
+
+ | PM > 0° |
+ ✅ 系统稳定 |
+
+
+ | PM = 0° |
+ ⚠️ 临界稳定 |
+
+
+ | PM < 0° |
+ ❌ 系统不稳定 |
+
+
+
+
+
💡 工程要求:通常要求 PM ∈ [30°, 60°]
+
• PM ≈ 45° ~ 60°: 良好阻尼特性
+
• PM 越大,超调量越小
+
+
+
与时域性能的关系
+
+
+
+ | PM |
+ ζ |
+ 超调量 |
+
+ | 30° | ≈ 0.3 | ≈ 37% |
+ | 45° | ≈ 0.45 | ≈ 20% |
+ | 60° | ≈ 0.6 | ≈ 10% ⭐ |
+
+
+
+
+
+
+ 📊 Bode 图与 Nyquist 图
+
+
+
Bode 图(伯德图)
+
组成:幅频特性图 + 相频特性图
+
+ - 横轴:频率 ω (对数刻度)
+ - 纵轴(幅频):20log|G(jω)| (dB)
+ - 纵轴(相频):∠G(jω) (度)
+
+
+
+
💡 优点:
+
+ - 便于绘制(渐近线近似)
+ - 直观读取稳定裕度
+ - 串联系统可图形叠加
+
+
+
+
Nyquist 图(奈奎斯特图)
+
定义:开环频率特性在复平面上的轨迹
+
+
+
🎯 奈奎斯特稳定判据:
+
Z = P − N
+
• Z: 闭环右半平面极点数
+ • P: 开环右半平面极点数
+ • N: 曲线逆时针包围 (−1, j0) 的圈数
+
稳定条件:Z = 0
+
+
+
+
+
+
+ 🔧 典型环节频率特性
+
+
+
+
1️⃣ 比例环节 K
+
• 幅频:20logK (dB) - 水平线
• 相频:0° - 水平线
+
+
+
+
2️⃣ 积分环节 1/s
+
• 幅频:-20dB/dec 斜率
• 相频:-90° - 水平线
+
+
+
+
3️⃣ 惯性环节 1/(Ts+1)
+
• 转折频率:ω = 1/T
• 幅频:低频 0dB,高频 -20dB/dec
• 相频:0° → -90°
+
+
+
+
+"""
+
+# 根轨迹分析知识卡片
+ROOT_LOCUS_KNOWLEDGE = """
+
+
+ 📚 根轨迹分析要点
+
+
+
+
+ 🎯 根轨迹基本概念
+
+
+
定义:当开环增益 K 从 0 → ∞ 变化时,闭环特征方程根(极点)在 s 平面上的运动轨迹
+
+
特征方程
+
+ 1 + KG(s)H(s) = 0
+
+
+
主要作用
+
+ - ✅ 直观显示增益对极点位置的影响
+ - ✅ 判断系统稳定性
+ - ✅ 选择合适的增益值
+ - ✅ 设计控制器参数
+
+
+
+
+
+
+ 📐 根轨迹绘制基本条件
+
+
+
+
+
1️⃣ 幅值条件(充要条件)
+
+ |KG(s0)H(s0)| = 1
+
+
💡 用途:确定增益 K 的值
+
+
+
+
2️⃣ 相角条件(充要条件)
+
+ ∠G(s0)H(s0) = (2k+1) × 180°
+
+
其中 k = 0, ±1, ±2, ...
+
💡 用途:判断 s 平面某点是否在根轨迹上
+
+
+
+
+
+
+ 🌟 根轨迹基本性质
+
+
+
+
+
+ | 规则 |
+ 内容 |
+
+
+ | 起点 |
+ K=0,位于开环极点 |
+
+
+ | 终点 |
+ K→∞,位于开环零点或无穷远 |
+
+
+ | 分支数 |
+ max(n, m),n=极点数,m=零点数 |
+
+
+ | 实轴段 |
+ 右侧零极点总数为奇数的区段 |
+
+
+ | 渐近线数 |
+ n − m 条 |
+
+
+ | 渐近线角度 |
+ φa = (2k+1)×180°/(n−m) |
+
+
+ | 渐近线交点 |
+ σa = (Σp − Σz)/(n−m) |
+
+
+
+
+
+
+
+ 🛡️ s 平面稳定性区域
+
+
+
+
+ | 区域 |
+ 条件 |
+ 稳定性 |
+
+
+ | 左半平面 |
+ Re(s) < 0 |
+ ✅ 稳定 |
+
+
+ | 虚轴 |
+ Re(s) = 0 |
+ ⚠️ 临界稳定 |
+
+
+ | 右半平面 |
+ Re(s) > 0 |
+ ❌ 不稳定 |
+
+
+
+
+
💡 虚轴(x=0)即为稳定性边界
+
根轨迹穿越虚轴点对应临界增益 Kc
+
+
+
+
+
+
+ 📏 阻尼比等值线
+
+
+
从原点出发的射线代表恒定阻尼比 ζ 的轨迹:
+
+ θ = arccos(ζ)
+
+
+
+
+ | 阻尼比 ζ |
+ 角度 θ |
+ 系统特性 |
+
+
+ | 0.5 |
+ 60° |
+ 欠阻尼,较大超调 |
+
+
+ | 0.707 |
+ 45° |
+ ⭐ 最佳阻尼 |
+
+
+
+
+
💡 工程应用:通过根轨迹与阻尼比等值线的交点,选择满足性能要求的增益 K
+
+
+
+
+"""
diff --git a/assets/styles.css b/assets/styles.css
new file mode 100644
index 0000000..be0e1e7
--- /dev/null
+++ b/assets/styles.css
@@ -0,0 +1,337 @@
+/* ==================== 全局样式 ==================== */
+* {
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.gradio-container {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif !important;
+ background: linear-gradient(135deg, #f5f7fa 0%, #e8eef5 100%) !important;
+}
+
+/* ==================== Emoji 显示修复 ==================== */
+* {
+ font-feature-settings: "liga" 1, "calt" 1, "kern" 1;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+.gr-markdown code {
+ background: transparent !important; padding: 0 !important; font-family: inherit !important;
+}
+.gr-html * {
+ background-clip: border-box !important; -webkit-background-clip: border-box !important; -webkit-text-fill-color: initial !important;
+}
+
+/* ==================== 标题区域 ==================== */
+.main-title {
+ text-align: center; font-size: 3em !important; font-weight: 900 !important; margin-bottom: 0.3em; letter-spacing: -1px; animation: titleGlow 3s ease-in-out infinite; color: #333;
+}
+h1.main-title {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
+}
+@keyframes titleGlow {
+ 0%, 100% { filter: brightness(1); }
+ 50% { filter: brightness(1.2); }
+}
+.subtitle {
+ text-align: center; color: #555; font-size: 1.2em; font-weight: 500; margin-bottom: 1.5em; letter-spacing: 0.5px;
+}
+p.subtitle { color: #555 !important; }
+
+/* ==================== 项目信息横幅 ==================== */
+.project-info-banner {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ padding: 25px 30px;
+ border-radius: 20px;
+ margin-bottom: 30px;
+ box-shadow: 0 10px 40px rgba(102, 126, 234, 0.35);
+ animation: bannerSlide 0.6s ease-out;
+ position: relative;
+ overflow: hidden;
+}
+
+.project-info-banner::before {
+ content: '';
+ position: absolute;
+ top: -50%;
+ right: -50%;
+ width: 200%;
+ height: 200%;
+ background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
+ animation: shimmer 8s infinite linear;
+}
+
+@keyframes shimmer {
+ 0% { transform: translate(-100%, -100%) rotate(0deg); }
+ 100% { transform: translate(100%, 100%) rotate(360deg); }
+}
+
+@keyframes bannerSlide {
+ from { transform: translateY(-30px); opacity: 0; }
+ to { transform: translateY(0); opacity: 1; }
+}
+
+/* ==================== 标签页样式 ==================== */
+.tab-nav {
+ background: white; border-radius: 12px; padding: 8px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); margin-bottom: 20px;
+}
+.tab-nav button {
+ font-weight: 600 !important; font-size: 1.1em !important; padding: 12px 24px !important; border-radius: 8px !important; border: none !important; transition: all 0.3s ease !important;
+}
+.tab-nav button:hover {
+ background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%) !important; transform: translateY(-2px) !important;
+}
+.tab-nav button.selected {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important; color: white !important; box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4) !important;
+}
+
+/* ==================== 卡片和分组样式 ==================== */
+.gr-group {
+ background: white !important;
+ border-radius: 20px !important;
+ padding: 28px !important;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.06), 0 2px 8px rgba(0, 0, 0, 0.04) !important;
+ border: 1px solid rgba(102, 126, 234, 0.08) !important;
+ margin-bottom: 20px !important;
+ transition: transform 0.3s ease, box-shadow 0.3s ease !important;
+ backdrop-filter: blur(10px);
+}
+.gr-group:hover {
+ transform: translateY(-6px) !important;
+ box-shadow: 0 12px 48px rgba(102, 126, 234, 0.12), 0 4px 16px rgba(102, 126, 234, 0.08) !important;
+}
+.card-title {
+ font-size: 1.5em;
+ font-weight: 700;
+ color: #333 !important;
+ margin-bottom: 18px;
+ border-bottom: 3px solid;
+ border-image: linear-gradient(90deg, #667eea, #764ba2) 1;
+ padding-bottom: 12px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ letter-spacing: -0.5px;
+}
+
+/* ==================== 输入框样式 ==================== */
+.gr-textbox input, .gr-textbox textarea {
+ border: 2px solid #e0e7ff !important; border-radius: 10px !important; padding: 12px 16px !important; font-size: 1em !important; transition: all 0.3s ease !important; background: #fafbff !important;
+}
+.gr-textbox input:focus, .gr-textbox textarea:focus {
+ border-color: #667eea !important; box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1) !important; background: white !important; transform: translateY(-2px);
+}
+.gr-textbox label {
+ font-weight: 600 !important; color: #4a5568 !important; margin-bottom: 8px !important;
+}
+
+/* ==================== 按钮样式 ==================== */
+button {
+ border-radius: 12px !important;
+ font-weight: 600 !important;
+ padding: 14px 28px !important;
+ font-size: 1.05em !important;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
+ border: none !important;
+ cursor: pointer !important;
+ letter-spacing: 0.3px;
+}
+.primary-btn, button[variant="primary"] {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
+ color: white !important;
+ box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4) !important;
+ position: relative;
+ overflow: hidden;
+}
+.primary-btn::before, button[variant="primary"]::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 0;
+ height: 0;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.2);
+ transform: translate(-50%, -50%);
+ transition: width 0.6s, height 0.6s;
+}
+.primary-btn:hover::before, button[variant="primary"]:hover::before {
+ width: 300px;
+ height: 300px;
+}
+.primary-btn:hover, button[variant="primary"]:hover {
+ transform: translateY(-4px) scale(1.03) !important;
+ box-shadow: 0 10px 30px rgba(102, 126, 234, 0.5) !important;
+}
+button[variant="secondary"] {
+ background-color: white !important;
+ color: #667eea !important;
+ border: 2px solid #667eea !important;
+ box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15) !important;
+}
+button[variant="secondary"]:hover {
+ background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%) !important;
+ transform: translateY(-3px) !important;
+ box-shadow: 0 6px 18px rgba(102, 126, 234, 0.25) !important;
+}
+
+/* ==================== 滑块样式 ==================== */
+.gr-slider { padding: 20px 10px !important; }
+.gr-slider input[type="range"] {
+ height: 8px !important; border-radius: 4px !important; background: linear-gradient(90deg, #667eea 0%, #764ba2 100%) !important;
+}
+
+/* ==================== 聊天机器人样式 ==================== */
+.modern-chatbot {
+ background: white !important;
+ border-radius: 20px !important;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08) !important;
+ border: 1px solid rgba(102, 126, 234, 0.1) !important;
+}
+.message.user {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
+ color: white !important;
+ border-radius: 20px 20px 4px 20px !important;
+ padding: 14px 18px !important;
+ box-shadow: 0 4px 12px rgba(102, 126, 234, 0.25) !important;
+ animation: messageSlideIn 0.3s ease-out;
+}
+.message.user * {
+ color: white !important;
+}
+.message.bot {
+ background: linear-gradient(135deg, #ffffff 0%, #f8f9ff 100%) !important;
+ border: 1px solid #e0e7ff !important;
+ border-radius: 20px 20px 20px 4px !important;
+ color: #2d3748 !important;
+ padding: 14px 18px !important;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06) !important;
+ animation: messageSlideIn 0.3s ease-out;
+}
+.message.bot * { color: #2d3748 !important; }
+.message.bot code {
+ background: #f0f4ff !important;
+ color: #667eea !important;
+ padding: 3px 8px !important;
+ border-radius: 6px !important;
+ border: 1px solid #e0e7ff !important;
+ font-family: 'Monaco', 'Menlo', 'Consolas', monospace !important;
+}
+
+@keyframes messageSlideIn {
+ from { opacity: 0; transform: translateY(10px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* ==================== 知识卡片样式 ==================== */
+.knowledge-card {
+ background: white !important;
+ border-radius: 16px !important;
+ padding: 24px !important;
+ box-shadow: 0 6px 24px rgba(102, 126, 234, 0.1) !important;
+ border: 1px solid rgba(102, 126, 234, 0.12) !important;
+ margin: 15px 0 !important;
+}
+
+.knowledge-card h3 {
+ margin-top: 0 !important;
+ padding-bottom: 15px !important;
+ border-bottom: 2px solid;
+ border-image: linear-gradient(90deg, #667eea, #764ba2) 1;
+}
+
+.knowledge-card h4 {
+ margin-top: 18px !important;
+ margin-bottom: 12px !important;
+ font-size: 1.1em !important;
+ font-weight: 700 !important;
+}
+
+.knowledge-card details {
+ margin: 12px 0 !important;
+ transition: all 0.3s ease !important;
+}
+
+.knowledge-card details[open] {
+ margin-bottom: 18px !important;
+}
+
+.knowledge-card details summary {
+ cursor: pointer !important;
+ user-select: none !important;
+ outline: none !important;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
+ position: relative !important;
+}
+
+.knowledge-card details summary:hover {
+ transform: translateX(5px) !important;
+ box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15) !important;
+}
+
+.knowledge-card details summary::marker {
+ font-size: 1.2em !important;
+}
+
+.knowledge-card details[open] summary {
+ margin-bottom: 8px !important;
+}
+
+.knowledge-card table {
+ border-collapse: collapse !important;
+ width: 100% !important;
+ margin: 15px 0 !important;
+ font-size: 0.95em !important;
+}
+
+.knowledge-card table td,
+.knowledge-card table th {
+ padding: 12px !important;
+ text-align: left !important;
+}
+
+.knowledge-card ul {
+ margin: 10px 0 !important;
+ padding-left: 25px !important;
+}
+
+.knowledge-card li {
+ margin: 6px 0 !important;
+ line-height: 1.6 !important;
+}
+
+.knowledge-card p {
+ margin: 10px 0 !important;
+ line-height: 1.7 !important;
+}
+
+.knowledge-card strong {
+ color: #667eea !important;
+ font-weight: 700 !important;
+}
+
+/* 滚动条美化 */
+.knowledge-card::-webkit-scrollbar,
+div[style*="overflow-y: auto"]::-webkit-scrollbar {
+ width: 8px !important;
+}
+
+.knowledge-card::-webkit-scrollbar-track,
+div[style*="overflow-y: auto"]::-webkit-scrollbar-track {
+ background: #f1f1f1 !important;
+ border-radius: 4px !important;
+}
+
+.knowledge-card::-webkit-scrollbar-thumb,
+div[style*="overflow-y: auto"]::-webkit-scrollbar-thumb {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
+ border-radius: 4px !important;
+}
+
+.knowledge-card::-webkit-scrollbar-thumb:hover,
+div[style*="overflow-y: auto"]::-webkit-scrollbar-thumb:hover {
+ background: linear-gradient(135deg, #5568d3 0%, #6a3f8a 100%) !important;
+}
+
+/* ... 其他CSS规则可以从原文件复制过来 ... */
diff --git a/chatbot.py b/chatbot.py
new file mode 100644
index 0000000..414bbb0
--- /dev/null
+++ b/chatbot.py
@@ -0,0 +1,83 @@
+import json
+import aiohttp
+import config
+
+async def chat_with_ai(message: str, history: list):
+ """
+ 与 AI 模型进行流式对话。
+ """
+ system_prompt = """你是一位精通自动控制原理的专家教授。请用清晰、准确、专业的中文来回答有关自动控制课程内容的问题。
+
+重要规则:
+1. 当需要表达数学公式时,必须使用 LaTeX 格式
+2. 行内公式使用 $公式$ 或 \\(公式\\)
+3. 独立公式使用 $$公式$$ 或 \\[公式\\]
+4. 例如:传递函数可以写成 $G(s) = \\frac{K}{s(s+1)}$
+5. 二阶系统标准形式:$$G(s) = \\frac{\\omega_n^2}{s^2 + 2\\zeta\\omega_n s + \\omega_n^2}$$
+
+请在适当的时候使用公式和示例来辅助解释。"""
+
+ if not config.API_KEY or config.API_KEY.strip() == "":
+ history.append({"role": "assistant", "content": "❌ 错误:API_KEY 未配置。请在 config.py 文件中配置 API_KEY。"})
+ yield history
+ return
+
+ history.append({"role": "user", "content": message})
+
+ # 添加临时的 "正在思考" 消息
+ history.append({"role": "assistant", "content": "正在思考..."})
+ yield history
+
+ bot_response = ""
+
+ try:
+ if config.API_TYPE == "deepseek":
+ api_url = f"{config.API_BASE_URL}/chat/completions"
+ messages_for_api = [{"role": "system", "content": system_prompt}] + history[:-1]
+
+ payload = {
+ "model": config.API_MODEL,
+ "messages": messages_for_api,
+ "stream": True, "temperature": 0.7, "max_tokens": 2048
+ }
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {config.API_KEY}"
+ }
+
+ async with aiohttp.ClientSession() as session:
+ async with session.post(api_url, json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=60)) as response:
+ if response.status == 200:
+ async for line in response.content:
+ line = line.decode('utf-8').strip()
+ if not line or line == "data: [DONE]": continue
+ if line.startswith("data: "): line = line[6:]
+ try:
+ data = json.loads(line)
+ if "choices" in data and len(data["choices"]) > 0:
+ delta = data["choices"][0].get("delta", {})
+ content = delta.get("content", "")
+ if content:
+ bot_response += content
+ history[-1]["content"] = bot_response
+ yield history
+ except json.JSONDecodeError:
+ pass
+
+ if not bot_response:
+ history[-1]["content"] = "⚠️ API 返回了空响应,请稍后重试。"
+ yield history
+ else:
+ error_text = await response.text()
+ history[-1]["content"] = f"❌ API请求出错 (状态码: {response.status}):\n{error_text}"
+ yield history
+ else:
+ history[-1]["content"] = f"❌ 不支持的 API 类型: {config.API_TYPE}"
+ yield history
+
+ except aiohttp.ClientError as e:
+ history[-1]["content"] = f"❌ 网络连接错误: {e}"
+ yield history
+ except Exception as e:
+ history[-1]["content"] = f"❌ 发生错误: {type(e).__name__}: {e}"
+ yield history
diff --git a/config.py b/config.py
new file mode 100644
index 0000000..1b2e024
--- /dev/null
+++ b/config.py
@@ -0,0 +1,19 @@
+# ==================== API 配置 ====================
+# 将 API 相关配置集中在此处,方便修改
+
+# DeepSeek API 配置说明:
+# 1. API_KEY: 您的 DeepSeek API 密钥
+# - 从 https://platform.deepseek.com/api_keys 获取
+# 2. API_BASE_URL: API 服务的基础 URL
+# - 官方地址: https://api.deepseek.com/v1
+# 3. API_MODEL: 使用的 DeepSeek 模型名称
+# - deepseek-chat (推荐) 或 deepseek-coder (代码专用)
+API_KEY = "sk-2292af2428d7419897ca1fb6e99ba6bc" # 请在此处填入您的 DeepSeek API 密钥
+API_BASE_URL = "https://api.deepseek.com/v1"
+API_MODEL = "deepseek-chat"
+API_TYPE = "deepseek" # 当前支持 "deepseek"
+
+# ==================== Gradio 应用启动配置 ====================
+SERVER_NAME = "0.0.0.0" # 监听所有网络接口
+SERVER_PORT = 7860 # 指定一个端口
+SHARE = False # 是否创建Gradio的公开分享链接
diff --git a/data_usage/usage_stats.json b/data_usage/usage_stats.json
index 7de30b1..1b02c3f 100644
--- a/data_usage/usage_stats.json
+++ b/data_usage/usage_stats.json
@@ -1,4 +1,4 @@
{
- "total_users": 1,
- "last_saved_at": 1760886050.8138287
+ "total_users": 15,
+ "last_saved_at": 1760890996.5595384
}
\ No newline at end of file
diff --git a/ui_components.py b/ui_components.py
new file mode 100644
index 0000000..da0af4f
--- /dev/null
+++ b/ui_components.py
@@ -0,0 +1,231 @@
+import gradio as gr
+from assets.knowledge_cards_html import (
+ TIME_DOMAIN_KNOWLEDGE,
+ FREQUENCY_DOMAIN_KNOWLEDGE,
+ ROOT_LOCUS_KNOWLEDGE
+)
+
+def create_header():
+ """创建页面顶部的标题、横幅和在线计数器"""
+ gr.HTML(" 自动控制理论AI+数智平台
")
+ gr.HTML("✨ 交互式控制系统分析与设计工具 | 时域·频域·根轨迹·AI问答 ✨
")
+
+ online_counter = gr.HTML(elem_id="online-counter")
+
+ gr.HTML("""
+
+
+
+
+
+ 🎓
+ 西北工业大学
+ Northwestern Polytechnical University
+
+
+ 2025年校级本科生建设项目资助
+
+
+
+ """)
+ return online_counter
+
+def create_time_domain_tab():
+ """创建时域分析选项卡的UI组件"""
+ ui_dict = {}
+ with gr.Row():
+ with gr.Column(scale=1):
+ with gr.Group():
+ gr.HTML("🔧 系统模型
")
+ ui_dict["tf_display"] = gr.Markdown(label="当前传递函数", elem_classes="output-display")
+ with gr.Row():
+ ui_dict["confirm_button"] = gr.Button("✓ 显示传递函数", variant="secondary", scale=1)
+ ui_dict["analyze_button"] = gr.Button("🚀 开始分析", variant="primary", scale=1, elem_classes="primary-btn")
+
+ with gr.Group():
+ gr.HTML("📈 动态性能指标
")
+ ui_dict["output_metrics"] = gr.Textbox(
+ label="Performance Metrics", lines=8, interactive=False, elem_classes="output-metrics"
+ )
+ with gr.Column(scale=2):
+ ui_dict["output_plot"] = gr.Plot(label="时域响应曲线", elem_classes="plot-container")
+ # 知识卡片
+ gr.HTML(f"""
+
+ {TIME_DOMAIN_KNOWLEDGE}
+
+
+ """)
+ return ui_dict
+
+def create_frequency_domain_tab():
+ """创建频域分析选项卡的UI组件"""
+ ui_dict = {}
+ with gr.Row():
+ with gr.Column(scale=1):
+ with gr.Group():
+ gr.HTML("🎚️ 调整系统增益
")
+ ui_dict["log_k_slider"] = gr.Slider(minimum=-4, maximum=4, value=1, step=0.01, label="对数增益 log₁₀(K)", info="💡 拖动滑块查看实时变化")
+ ui_dict["k_number_display"] = gr.Number(value=10.0, label="当前增益 K", interactive=False, elem_classes="gain-display")
+ with gr.Group():
+ gr.HTML("🔧 当前系统模型
")
+ ui_dict["tf_display"] = gr.Markdown(label="含增益K的开环传递函数", elem_classes="output-display")
+ with gr.Group():
+ gr.HTML("📊 稳定裕度分析
")
+ ui_dict["metrics_display"] = gr.Textbox(label="Stability Margins", lines=4, interactive=False, elem_classes="output-metrics")
+ ui_dict["stability_display"] = gr.Markdown(elem_classes="stability-result")
+ with gr.Column(scale=2):
+ ui_dict["plot_output"] = gr.Plot(label="频域响应图", elem_classes="plot-container")
+ # 知识卡片
+ gr.HTML(f"""
+
+ {FREQUENCY_DOMAIN_KNOWLEDGE}
+
+
+ """)
+ return ui_dict
+
+def create_root_locus_tab():
+ """创建根轨迹分析选项卡的UI组件"""
+ ui_dict = {}
+ with gr.Row():
+ with gr.Column(scale=1):
+ with gr.Group():
+ gr.HTML("🎚️ 调整系统增益
")
+ ui_dict["log_k_slider"] = gr.Slider(minimum=-4, maximum=4, value=1, step=0.01, label="对数增益 log₁₀(K)", info="💡 拖动滑块观察极点移动")
+ ui_dict["k_number_display"] = gr.Number(value=10.0, label="当前增益 K", interactive=False, elem_classes="gain-display")
+ with gr.Group():
+ gr.HTML("📍 闭环极点位置
")
+ ui_dict["poles_display"] = gr.Textbox(label="Closed-Loop Pole Locations", lines=6, interactive=False, elem_classes="output-metrics")
+ with gr.Column(scale=2):
+ ui_dict["plot_output"] = gr.Plot(label="根轨迹图")
+ gr.HTML(f"""
+
+ {ROOT_LOCUS_KNOWLEDGE}
+
+
+ """)
+ return ui_dict
+
+def create_chatbot_tab():
+ """创建AI问答选项卡的UI组件"""
+ ui_dict = {}
+ ui_dict["chatbot"] = gr.Chatbot(
+ label="🎓 自控原理AI助教",
+ type="messages",
+ avatar_images=("https://img.icons8.com/fluency/96/user-male-circle.png", "https://img.icons8.com/fluency/96/chatbot.png"),
+ height=650,
+ latex_delimiters=[
+ {"left": "$$", "right": "$$", "display": True},
+ {"left": "$", "right": "$", "display": False},
+ {"left": "\\[", "right": "\\]", "display": True},
+ {"left": "\\(", "right": "\\)", "display": False}
+ ],
+ elem_classes="modern-chatbot",
+ show_copy_button=True
+ )
+ with gr.Row():
+ ui_dict["chat_input"] = gr.Textbox(label="", placeholder="💬 输入您的问题...", scale=4, lines=2, max_lines=4)
+ with gr.Column(scale=1, min_width=120):
+ ui_dict["send_button"] = gr.Button("📤 发送", variant="primary", size="lg")
+ ui_dict["clear_button"] = gr.Button("🗑️ 清空", variant="secondary", size="lg")
+
+ gr.Examples(
+ examples=["什么是传递函数?", "如何判断系统稳定性?", "解释Bode图的物理意义", "PID控制器各参数的作用"],
+ inputs=ui_dict["chat_input"],
+ label="💡 试试这些问题:"
+ )
+ return ui_dict
diff --git a/user_stats.py b/user_stats.py
new file mode 100644
index 0000000..e13b17c
--- /dev/null
+++ b/user_stats.py
@@ -0,0 +1,108 @@
+import json
+import os
+import time
+from threading import Lock
+import atexit
+
+# --- 配置 ---
+TIMEOUT_SECONDS = 300 # 5分钟无活动视为离线
+STATS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data_usage")
+STATS_FILE = os.path.join(STATS_DIR, "usage_stats.json")
+SAVE_INTERVAL_SECONDS = int(os.environ.get("USAGE_SAVE_INTERVAL", "60"))
+
+# --- 全局变量 ---
+active_users = {} # {session_id: last_active_timestamp}
+users_lock = Lock()
+stats_lock = Lock()
+total_users = 0
+seen_sessions = set()
+_LAST_SAVE_TS = 0.0
+
+# --- 内部函数 ---
+def _ensure_stats_dir():
+ try:
+ os.makedirs(STATS_DIR, exist_ok=True)
+ except OSError:
+ pass
+
+def _load_usage_stats():
+ global total_users, _LAST_SAVE_TS
+ try:
+ if os.path.exists(STATS_FILE):
+ with open(STATS_FILE, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ total_users = int(data.get("total_users", 0))
+ _LAST_SAVE_TS = float(data.get("last_saved_at", time.time()))
+ except (IOError, json.JSONDecodeError):
+ total_users = 0
+ _LAST_SAVE_TS = time.time()
+
+def _save_usage_stats():
+ global _LAST_SAVE_TS
+ try:
+ with stats_lock:
+ tmp_path = STATS_FILE + ".tmp"
+ with open(tmp_path, "w", encoding="utf-8") as f:
+ json.dump({"total_users": total_users, "last_saved_at": time.time()}, f, ensure_ascii=False, indent=2)
+ os.replace(tmp_path, STATS_FILE)
+ _LAST_SAVE_TS = time.time()
+ except (IOError, OSError):
+ pass
+
+def _maybe_save_usage_stats():
+ if time.time() - _LAST_SAVE_TS >= SAVE_INTERVAL_SECONDS:
+ _save_usage_stats()
+
+# --- 外部接口 ---
+def update_user_activity(session_id: str):
+ """更新用户活跃时间并在首次出现时累计总人数。"""
+ current_time = time.time()
+ with users_lock:
+ active_users[session_id] = current_time
+
+ with stats_lock:
+ global total_users
+ if session_id not in seen_sessions:
+ seen_sessions.add(session_id)
+ total_users += 1
+
+def get_active_users_count():
+ """获取当前活跃用户数量"""
+ current_time = time.time()
+ with users_lock:
+ expired_users = [uid for uid, last_time in active_users.items() if current_time - last_time > TIMEOUT_SECONDS]
+ for uid in expired_users:
+ del active_users[uid]
+ return len(active_users)
+
+def get_online_status_html():
+ """生成在线人数与总人数的显示HTML,并按需触发持久化。"""
+ count = get_active_users_count()
+ with stats_lock:
+ total = total_users
+
+ _maybe_save_usage_stats()
+
+ return f"""
+
+ 🌐
+ 在线人数:
+ {count}
+ |
+ 👥
+ 总人数:
+ {total}
+
+ """
+
+# --- 初始化 ---
+_ensure_stats_dir()
+_load_usage_stats()
+atexit.register(_save_usage_stats) # 确保程序退出时保存数据