Python数据看板框架选型指南:Dash、Streamlit与Panel实战对比
1. 项目概述三款主流Python数据看板框架的实战对比不是纸上谈兵如果你正坐在电脑前手头刚跑完一个Pandas清洗好的销售数据集心里盘算着“怎么把这堆数字变成老板能一眼看懂的动态图表”那今天这篇内容就是为你写的。我过去三年里用Dash、Panel、Streamlit这三款工具搭过27个不同规模的数据看板——从给市场部同事看的周度转化漏斗小屏到给CTO汇报的实时IoT设备健康监控大屏中间踩过的坑、调过的参数、改过的架构全在这儿了。这不是一篇罗列官网API的说明书而是一份带着体温的实操手记Dash像一位严谨的建筑师适合需要精细控制每个像素和交互逻辑的中大型系统Streamlit像一位快手程序员5分钟就能把Jupyter里的分析结果变成可分享的Web界面但想加个自定义按钮得绕点路Panel则像一位双语翻译家既能无缝嵌入Bokeh/Plotly这些专业可视化库又能用类似Streamlit的简洁语法快速搭建特别适合科研团队和数据工程师混搭的场景。无论你是刚学完Matplotlib想迈出第一步的新手还是正在为公司选型纠结的技术负责人这篇文章都会给你明确的答案在什么业务场景下该选谁、为什么这么选、实际部署时最常卡在哪一步。接下来所有内容都来自真实项目日志、服务器日志截图、用户反馈记录和反复压测后的性能数据不讲虚的。2. 整体设计思路与框架选型逻辑为什么不是“哪个最好”而是“哪个最合适”2.1 核心设计哲学差异从底层架构看本质区别要真正理解这三款框架的适用边界必须先拆开它们的“发动机”。很多人一上来就比“谁写代码少”这就像问“锤子、电钻和激光切割机哪个更好”——答案永远取决于你要钉钉子、打孔还是切割钛合金板。Dash的底层是Flask React Plotly.js三位一体。它本质上是一个“前端渲染引擎”Python后端只负责处理数据逻辑和回调callback所有UI组件按钮、下拉框、图表最终都由React在浏览器里动态生成和响应。这意味着你拥有对DOM的完全控制权可以精确到毫秒级地管理状态更新、实现复杂的联动逻辑比如“选中A图表中的某条折线自动高亮B图表中对应时间点的所有散点”但代价是必须理解React的单向数据流概念且每次交互都要触发一次Python后端计算——当你的数据量超过50万行或者用户并发数超过30人时后端CPU很容易成为瓶颈。我在为一家物流客户做运单时效看板时最初用Dash实现了完美的多维度钻取但上线后发现高峰期API响应延迟飙升到8秒最后不得不把核心聚合逻辑提前固化到PostgreSQL物化视图里才把延迟压回1.2秒以内。Streamlit走的是另一条路它把整个Python脚本当作一个“反应式函数”。你写的每一行代码包括st.dataframe(df)、st.line_chart()都会在用户每次交互比如拖动滑块后被完整重跑一遍。这种设计让初学者上手极快——你根本不用想“状态怎么同步”“回调怎么注册”只要保证脚本从上到下能跑通就行。但它也埋下了隐患如果脚本里有耗时操作比如每次都要重新读取1GB的Parquet文件用户每点一次按钮就要等10秒。我见过最典型的反模式是某团队把模型训练代码直接塞进Streamlit脚本里结果用户点一次“重新训练”按钮整个应用就卡死40分钟。后来我们强制要求所有耗时2秒的操作必须用st.cache_data或st.cache_resource装饰器缓存且缓存键必须包含所有影响结果的参数比如st.cache_data(ttl3600, show_spinnerFalse)否则缓存形同虚设。Panel则采用了“混合编译”策略。它既支持类似Streamlit的声明式语法pn.Column(pn.pane.Markdown(## 标题), pn.widgets.Select(...))也允许你直接使用Bokeh/Plotly/Matplotlib原生对象pn.panel(bokeh_fig, sizing_modestretch_width)。它的核心优势在于统一的状态管理层Param你可以定义一个参数化类把所有UI控件、数据源、图表逻辑都封装在里面然后通过param.depends装饰器声明依赖关系。比如定义一个SalesDashboard(param.Parameterized)类里面有个region参数对应下拉框、一个data_source参数对应数据加载函数再写一个param.depends(region, data_source)修饰的plot_sales()方法——Panel会自动监听这两个参数的变化并只在它们改变时重绘图表。这种设计让复杂看板的维护成本大幅降低尤其适合需要频繁迭代的BI场景。我们在为一家零售连锁企业做门店业绩看板时原本Dash版本需要维护17个独立callback迁移到Panel后整个逻辑压缩到一个参数化类的4个方法里后续新增“按促销活动筛选”功能只改了3行代码。提示选型的第一道分水岭不是看文档多厚而是看你的团队是否具备前端调试能力。如果团队里没人会打开Chrome开发者工具看Network请求、没人能读懂React组件树Dash的调试成本会指数级上升如果团队全是数据科学家连JavaScript变量声明都不熟Streamlit的“重跑脚本”模型反而更安全可控。2.2 业务场景匹配矩阵用真实项目验证选择逻辑光讲原理不够我们用三个真实项目来验证选型逻辑。每个项目都记录了从开发到上线的关键指标首屏加载时间、代码行数不含注释、部署复杂度1-5分5分为最高、后期维护难度1-5分。项目类型DashStreamlitPanel关键决策依据内部运营日报日活10人需求每日自动拉取CRM数据生成销售漏斗渠道ROI图表支持按日期范围筛选首屏4.2s代码387行部署4分维护3分首屏1.8s代码92行部署2分维护2分首屏2.3s代码156行部署3分维护2分Streamlit胜出用户只需看结果无需复杂交互数据量小10万行重跑脚本无压力部署只需streamlit run app.py运维零学习成本客户自助分析平台日活200需求允许客户上传CSV选择字段生成交互式图表支持保存/分享仪表盘链接首屏6.7s含首次JS加载代码1240行部署5分维护4分首屏3.1s代码412行部署3分维护4分首屏2.9s代码583行部署3分维护3分Panel胜出需深度集成Plotly的FigureWidget实现客户端图表编辑Dash的React生态虽强但学习曲线陡峭Streamlit不支持真正的客户端状态持久化用户上传的CSV无法在页面刷新后保留Panel的pn.state.session_args可完美解决工业设备预测性维护大屏7x24运行需求实时接收MQTT传感器数据显示设备健康评分热力图异常时自动弹窗告警支持历史数据回溯首屏3.5s代码892行部署5分维护5分首屏5.2sWebSocket连接不稳定代码267行部署4分维护5分首屏2.8s代码631行部署4分维护4分Dash胜出需要精确控制WebSocket心跳间隔必须500ms和告警弹窗Z-index层级Streamlit的st.experimental_rerun()在长连接场景下易导致状态错乱Panel的pn.bind虽支持异步更新但对MQTT QoS 1级消息的ACK确认处理不如Dash的Flask-SocketIO原生可靠这个矩阵揭示了一个关键事实没有银弹框架只有适配场景的工具。当你看到“Streamlit代码最少”时要立刻追问“这个‘最少’是否以牺牲可扩展性为代价”当听说“Dash最灵活”时要确认“团队是否准备好为这份灵活性支付额外的运维成本”2.3 部署与运维成本的真实账本别让上线成为噩梦很多技术选型讨论止步于开发阶段但真正的考验在上线后。我整理了三款框架在主流云环境AWS EC2 Nginx Gunicorn下的部署实录Dash部署必须配置Gunicorn的--workers参数建议2 * CPU核心数 1因为每个worker进程都是独立的Flask实例需避免callback冲突。最关键的陷阱是静态资源路径Dash默认将assetsCSS/JS放在/assets/路径但Nginx反向代理时若未显式配置location /assets/会导致图表空白。我在某次紧急上线时因忘记在Nginx配置里加location /assets/ { alias /var/www/mydash/assets/; }导致所有用户看到的都是白屏排查了3小时才发现是这个10行配置的问题。此外Dash的requests_pathname_prefix参数必须与Nginx的location路径严格一致否则路由全部404。Streamlit部署表面最简单streamlit run app.py --server.port8501但暗藏玄机。默认的server.headlesstrue模式在Docker容器里可能因缺少字体库导致中文图表乱码必须在Dockerfile里安装fonts-wqy-zenhei并设置ENV MPLBACKENDAgg。更致命的是会话隔离Streamlit为每个用户创建独立会话当并发用户超50时内存占用会线性增长每个会话约占用150MB RAM。我们曾用ps aux --sort-%mem | head -20命令发现一个Streamlit进程占用了4.2GB内存根源是用户上传的10个Excel文件被缓存在各自会话里。解决方案是强制使用st.cache_data(max_entries10)并设置ttl1800同时在config.toml里配置server.maxUploadSize 100单位MB限制单文件大小。Panel部署采用panel serve命令启动但生产环境必须配合--num-procs进程数和--autoreload热重载参数。最大的坑是Bokeh服务器兼容性Panel 1.2版本默认使用Bokeh 3.x而某些旧版Plotly导出的HTML嵌入Panel时会因bokeh.embedAPI变更报错。我们的解法是固定依赖版本pip install panel1.2.1 bokeh2.4.3 plotly5.15.0并在requirements.txt顶部添加# WARNING: Do not upgrade bokeh beyond 2.4.3 without testing Panel integration的醒目注释。注意所有框架在容器化部署时都必须将/tmp目录挂载为独立卷。Dash的临时缓存、Streamlit的上传文件、Panel的Bokeh渲染临时文件都会写入/tmp若容器重启导致/tmp清空用户上传的文件将永久丢失。这是我在三个项目中重复踩过的坑血的教训。3. 核心细节解析与实操要点从代码片段到生产级配置3.1 Dash如何写出既高效又可维护的回调链Dash的callback机制是双刃剑。写得好逻辑清晰如流水写得差就成了“回调地狱”。我总结出一套经过27个项目验证的四步回调设计法第一步明确数据流单向性永远遵循“输入→处理→输出”单向链。例如一个销售看板有三个输入日期范围选择器date_range、地区下拉框region_select、产品类别多选框category_multi。对应的输出是主图表main_chart、汇总卡片summary_cards、数据表格data_table。绝不能让main_chart的点击事件反过来修改date_range的值——这会破坏数据流导致难以调试的状态漂移。第二步用dash.dependencies.State替代冗余输入新手常犯的错误是把所有相关参数都列为Input。比如当用户点击“导出CSV”按钮时你需要访问当前图表的数据但这个数据本身不是用户直接输入的。正确做法是app.callback( Output(download-data, data), Input(export-btn, n_clicks), State(main-chart, figure), # 用State获取当前图表状态 prevent_initial_callTrue ) def export_csv(n_clicks, figure): # 从figure[data]中提取原始数据 df pd.DataFrame(figure[data][0][x], figure[data][0][y]) return dcc.send_data_frame(df.to_csv, sales_export.csv)State不会触发callback但能在需要时安全读取组件状态避免不必要的重计算。第三步批量更新用dash.dependencies.Output元组当多个输出依赖同一组输入时务必合并为一个callback。比如切换地区时主图表、汇总卡片、数据表格都要更新。如果写成三个独立callback# ❌ 错误三次独立调用数据加载三次 app.callback(Output(main-chart, figure), Input(region-select, value)) def update_chart(region): load_data(region)... app.callback(Output(summary-cards, children), Input(region-select, value)) def update_cards(region): load_data(region)... # 又加载一次 app.callback(Output(data-table, data), Input(region-select, value)) def update_table(region): load_data(region)... # 再加载一次正确写法是单次加载一次返回# ✅ 正确一次加载三次输出 app.callback( [Output(main-chart, figure), Output(summary-cards, children), Output(data-table, data)], Input(region-select, value) ) def update_all(region): df load_data(region) # 仅加载一次 return ( create_chart(df), create_summary_cards(df), df.to_dict(records) )实测表明这种写法在数据量10万行时将地区切换响应时间从3.2秒降至1.1秒。第四步异步处理耗时操作对于必须执行的耗时任务如调用外部API、运行轻量模型用dash.long_callbackDash 2.9from dash.long_callback import DiskcacheLongCallbackManager import diskcache cache diskcache.Cache(./cache) long_callback_manager DiskcacheLongCallbackManager(cache) app Dash(__name__, long_callback_managerlong_callback_manager) app.long_callback( Output(result-div, children), Input(run-btn, n_clicks), running[ (Output(run-btn, disabled), True, False), (Output(loading-div, children), Running..., ), ], prevent_initial_callTrue ) def run_heavy_task(n_clicks): time.sleep(10) # 模拟耗时任务 return Task completed!这会让按钮变灰、显示加载中且任务在后台线程执行不阻塞主线程。注意diskcache必须安装且./cache目录需有写权限。3.2 Streamlit绕过“重跑脚本”陷阱的五大缓存策略Streamlit的“重跑脚本”模型是其灵魂也是最大陷阱。我归纳出五种缓存场景及对应解法覆盖95%的性能问题场景1静态数据集如CSV/Excel用st.cache_data但必须指定ttlTime-To-Live和max_entriesst.cache_data(ttl3600, max_entries10) # 缓存1小时最多存10个版本 def load_sales_data(): return pd.read_csv(sales_2023.csv) df load_sales_data() # 每次重跑都命中缓存不读磁盘⚠️ 警告若CSV文件在外部被修改缓存不会自动失效必须手动调用st.cache_data.clear()或重启应用。场景2动态查询数据如SQL查询缓存键必须包含所有影响结果的参数st.cache_data(ttl600, show_spinnerLoading data...) def query_sales_data(start_date, end_date, region): # SQL查询必须包含所有参数 query fSELECT * FROM sales WHERE date BETWEEN {start_date} AND {end_date} AND region{region} return pd.read_sql(query, conn) # 在Streamlit脚本中 start st.date_input(Start Date) end st.date_input(End Date) region st.selectbox(Region, [North, South]) df query_sales_data(start, end, region) # 参数变化时自动失效缓存场景3机器学习模型加载模型文件大100MB且加载耗时用st.cache_resource专为不可变资源设计st.cache_resource def load_model(): return joblib.load(model_v2.pkl) # 只加载一次跨会话共享 model load_model() # 所有用户共享同一个模型实例⚠️ 关键区别st.cache_data为每个会话创建独立副本st.cache_resource全局共享单例。场景4图表渲染Plotly/Matplotlib避免在脚本中直接调用st.plotly_chart(fig)因为fig对象本身可能很大。应缓存图表生成逻辑st.cache_data(ttl1800) def create_interactive_chart(df, x_col, y_col): fig px.line(df, xx_col, yy_col, titlef{y_col} over {x_col}) fig.update_layout(height400) return fig # 在主脚本中 x_axis st.selectbox(X Axis, df.columns) y_axis st.selectbox(Y Axis, df.columns) fig create_interactive_chart(df, x_axis, y_axis) st.plotly_chart(fig, use_container_widthTrue)场景5用户上传文件处理上传文件是Streamlit的高频痛点。必须用st.file_uploader配合缓存且处理前校验uploaded_file st.file_uploader(Choose a CSV file, typecsv) if uploaded_file is not None: st.cache_data def process_uploaded_file(file_bytes): # 先校验文件大小 if len(file_bytes) 100 * 1024 * 1024: # 100MB st.error(File too large! Max 100MB.) return None # 再读取 df pd.read_csv(io.BytesIO(file_bytes)) if len(df) 50000: st.warning(fLarge file detected ({len(df)} rows). Sampling to 50k.) df df.sample(50000, random_state42) return df df process_uploaded_file(uploaded_file.getvalue()) if df is not None: st.dataframe(df.head())3.3 Panel参数化类Param的工程化实践Panel的param.Parameterized类是其核心竞争力但用不好容易变成“过度设计”。我的经验是只在需要跨组件状态同步时才启用。以下是经过生产验证的参数化类模板import param import panel as pn import pandas as pd import hvplot.pandas pn.extension(tabulator) # 启用高级表格 class SalesDashboard(param.Parameterized): # 定义UI参数会自动映射为控件 date_range param.DateRange(default(pd.Timestamp(2023-01-01), pd.Timestamp(2023-12-31))) region param.ObjectSelector(defaultAll, objects[All, North, South, East, West]) category param.ListSelector(default[Electronics], objects[Electronics, Clothing, Home, Beauty]) # 定义数据源参数不暴露为UI但参与依赖 _data_source param.String(defaultsales_db) # 内部参数以下划线开头 # 定义输出参数用于绑定到视图 main_plot param.ClassSelector(class_pn.pane.HoloViews, defaultNone) summary_stats param.ClassSelector(class_pn.pane.Markdown, defaultNone) # 初始化时设置布局 def __init__(self, **params): super().__init__(**params) # 创建控件实例非参数避免循环依赖 self._date_picker pn.widgets.DateRangeSlider(nameDate Range, startpd.Timestamp(2023-01-01), endpd.Timestamp(2023-12-31)) self._region_selector pn.widgets.Select(nameRegion, optionsself.param.region.objects) self._category_selector pn.widgets.MultiChoice(nameCategory, optionsself.param.category.objects, valueself.category) # 绑定参数到控件 self.param.watch(self._update_date_range, date_range) self.param.watch(self._update_region, region) self.param.watch(self._update_category, category) # 依赖方法当参数变化时自动触发 param.depends(date_range, region, category, watchTrue) def _update_data(self): 核心数据加载逻辑只在参数变化时执行 start, end self.date_range # 模拟数据库查询 df self._load_data_from_db(start, end, self.region, self.category) # 更新输出参数 self.main_plot df.hvplot.line(xdate, yrevenue, height400) self.summary_stats pn.pane.Markdown(f## Summary\n- Total Revenue: ${df[revenue].sum():,.0f}\n- Avg Order Value: ${df[revenue].mean():.2f}) # 辅助方法处理控件事件 def _update_date_range(self, event): self.date_range event.new def _update_region(self, event): self.region event.new def _update_category(self, event): self.category event.new # 数据加载可缓存 param.depends(_data_source, watchFalse) # 不监听变化手动调用 def _load_data_from_db(self, start, end, region, category): # 这里放真实的数据库查询逻辑 # 为演示返回模拟数据 dates pd.date_range(start, end, freqD) df pd.DataFrame({ date: dates, revenue: np.random.normal(10000, 2000, len(dates)), orders: np.random.poisson(50, len(dates)) }) return df # 构建UI视图 param.depends(main_plot, summary_stats) def view(self): return pn.Column( pn.Row(self._date_picker, self._region_selector, self._category_selector), self.main_plot, self.summary_stats, sizing_modestretch_width ) # 实例化并发布 dashboard SalesDashboard() pn.serve(dashboard.view, port5006, address0.0.0.0, allow_websocket_origin[*])这个模板的关键在于param.Parameterized类封装了所有状态和逻辑UI控件_date_picker等作为私有属性存在避免污染参数命名空间param.depends精准控制更新时机watchTrue表示参数变化时立即触发watchFalse默认表示只在方法被调用时检查_load_data_from_db方法用param.depends(_data_source)装饰但watchFalse意味着它不会自动执行只在_update_data中被显式调用确保数据加载时机可控view()方法返回一个pn.Column这是Panel的“视图层”与逻辑层完全分离便于单元测试。4. 实操过程与核心环节实现从零搭建一个可交付的销售看板4.1 环境准备与依赖管理避免“在我机器上能跑”的陷阱所有框架都面临同样的问题本地开发环境和生产环境的依赖差异。我的标准流程是Step 1用pip-tools生成锁定文件不直接用pip freeze requirements.txt因为这会包含所有传递依赖包括setuptools等构建工具导致生产环境臃肿。正确流程# 1. 编写顶级依赖requirements.in echo dash2.12.2 requirements.in echo plotly5.18.0 requirements.in echo pandas2.0.3 requirements.in # 2. 生成锁定文件包含所有精确版本 pip-compile requirements.in --output-file requirements.txt # 3. 安装确保所有环境一致 pip install -r requirements.txtpip-compile会递归解析所有依赖并生成带哈希值的requirements.txt例如dash2.12.2 \ --hashsha256:abc123... \ --hashsha256:def456...这样pip install -r requirements.txt会在安装时校验每个包的SHA256杜绝“版本漂移”。Step 2Docker镜像分层优化针对不同框架Dockerfile有细微差别Dash镜像基础镜像用python:3.11-slim-bookwormDebian 12安装gcc和libpq-dev为psycopg2编译FROM python:3.11-slim-bookworm RUN apt-get update apt-get install -y gcc libpq-dev rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . /app WORKDIR /app EXPOSE 8050 CMD [gunicorn, --bind, 0.0.0.0:8050, --workers, 4, app:server]Streamlit镜像必须安装中文字体否则st.pyplot()生成的图表中文乱码FROM python:3.11-slim-bookworm # 安装中文字体 RUN apt-get update apt-get install -y fonts-wqy-zenhei rm -rf /var/lib/apt/lists/ # 设置环境变量 ENV MPLBACKENDAgg ENV FONTCONFIG_PATH/etc/fonts COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . /app WORKDIR /app EXPOSE 8501 CMD [streamlit, run, app.py, --server.port8501, --server.address0.0.0.0]Panel镜像需预编译Bokeh的JS资源避免首次访问时动态编译卡顿FROM python:3.11-slim-bookworm RUN apt-get update apt-get install -y gcc rm -rf /var/lib/apt/lists/ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 预编译Bokeh资源 RUN python -c import bokeh; bokeh.resources.get_sri_hashes_for_version(bokeh.__version__) COPY . /app WORKDIR /app EXPOSE 5006 CMD [panel, serve, app.py, --port, 5006, --address, 0.0.0.0, --num-procs, 2]Step 3配置文件分离绝不把数据库密码、API密钥写死在代码里。创建config.pyimport os from dataclasses import dataclass dataclass class Config: DB_URL: str os.getenv(DB_URL, sqlite:///data.db) API_KEY: str os.getenv(API_KEY, ) DEBUG: bool os.getenv(DEBUG, False).lower() true config Config()然后在应用中导入from config import config engine create_engine(config.DB_URL)Docker启动时传入docker run -e DB_URLpostgresql://user:passdb:5432/sales \ -e API_KEYyour_key_here \ -p 8050:8050 my-dash-app4.2 核心功能实现销售看板的三大模块详解我们以一个完整的销售看板为例展示三款框架如何实现相同功能。模块1动态数据加载与缓存Dash实现用dcc.Store组件在前端缓存数据避免后端重复查询app.layout html.Div([ dcc.Store(idsales-data-store), # 前端存储 dcc.Interval(idinterval-component, interval300*1000, n_intervals0), # 每5分钟刷新 # ... 其他组件 ]) app.callback( Output(sales-data-store, data), Input(interval-component, n_intervals), prevent_initial_callFalse ) def refresh_data(n): df load_latest_sales() # 从数据库加载最新数据 return df.to_dict(records) # 转为JSON序列化格式dcc.Store将数据存在浏览器localStorage下次页面加载时可直接读取首屏速度提升60%。Streamlit实现用st.cache_data配合ttlst.cache_data(ttl300) # 5分钟过期 def get_latest_sales(): return pd.read_sql(SELECT * FROM sales ORDER BY date DESC LIMIT 10000, conn) df get_latest_sales() # 自动缓存5分钟内重复调用不查库Panel实现用param.depends监听定时器class SalesDashboard(param.Parameterized): # ... 其他参数 last_updated param.Date(defaultpd.Timestamp.now()) param.depends(last_updated, watchTrue) def _refresh_data(self): self._sales_df get_latest_sales() # 加载数据 # 触发其他依赖更新 self.param.trigger(sales_df) # 手动触发参数变化 # 启动定时刷新在__init__中 def __init__(self, **params): super().__init__(**params) # 使用threading.Timer实现后台定时 self._timer threading.Timer(300, self._auto_refresh) # 5分钟 self._timer.start() def _auto_refresh(self): self.last_updated pd.Timestamp.now() self._timer threading.Timer(300, self._auto_refresh) self._timer.start()模块2交互式图表联动需求点击主图表中的某条折线下方的明细表格只显示该产品类别的数据。Dash实现用clickData事件app.callback( Output(detail-table, data), Input(main-chart, clickData), State(sales-data-store, data), prevent_initial_callTrue ) def display_details(click_data, stored_data): if click_data is None: return [] # 解析点击数据获取产品类别 product click_data[points][0][customdata][0] # customdata在绘图时设置 df pd.DataFrame(stored_data) return df[df[product] product].to_dict(records)关键点绘图时必须设置customdatafig px.line(df, xdate, yrevenue, colorproduct, custom_data[product]) # 将product列传入customdataStreamlit实现用st.plotly_chart的on_select回调Streamlit 1.28fig px.line(df, xdate, yrevenue, colorproduct) selected st.plotly_chart(fig, on_selectrerun, keymain-chart) if selected and selection in selected and selected[selection][points]: product selected[selection][points][0][customdata][0] detail_df df[df[product] product] st.dataframe(detail_df)Panel实现用hvplot的streams.Selection1Dimport holoviews as hv from holoviews import streams # 创建可选择的图表 curve df.hvplot.line(xdate, yrevenue, byproduct, width800) selection_stream streams.Selection1D(sourcecurve) # 定义响应函数 pn.depends(selection_stream.param.selection) def update_detail_table(selection): if selection: product df.iloc[selection[0]][product] # 获取选中行的产品名 detail_df df[df[product] product] return pn.widgets.Tabulator(detail_df, height300) return pn.pane.Markdown(Select a line to see details) # 在view()中组合 pn.Column(curve, update_detail_table)**模块3用户权限与数据