实验2进阶版数据看板的设计与实现

一、实验目的:

  1. 在基础校园消费看板上增加动态视觉效果,掌握组件渐入动画、图表悬浮提示和自适应布局的方法。
  2. 掌握日期、消费类别和支付方式的组合筛选,使指标与图表根据同一数据范围更新。
  3. 理解图表选择事件与会话状态的关系,实现点击类别柱状图后联动其他模块。
  4. 制作消费排行榜、星期与类别热力图、预算进度仪表盘,从多个角度分析消费数据。
  5. 验证交互前后的统计口径、空数据处理和界面显示效果。

二、实验环境:

  • 硬件:可运行 Python 和浏览器的计算机。
  • 操作系统:macOS。
  • 开发语言:Python 3.11。
  • 主要依赖:Streamlit 1.66.0、Pandas 3.0.6、Plotly 7.1.0。
  • 运行及截图环境:Codex 内置浏览器,通过本地服务访问。
  • 数据来源:沿用基础实验的校园消费模拟数据,周期为 2026 年 9 月 1 日至 9 月 30 日。原始记录 908 条,清洗后保留 900 条。
  • 验证工具:浏览器实际操作、Streamlit AppTest,以及独立金额汇总核对。

三、实验内容:

1. 进阶任务设计

本实验继续使用校园消费场景,在基础看板上完成三项进阶任务。页面顶部设置日期范围、消费类别和支付方式筛选,随后依次展示指标卡、类别对比与支付占比、每日趋势、热力图与预算仪表盘、消费排行榜和明细表。

进阶任务 本实验完成的内容
动态视觉效果优化 指标卡和图表渐入动画、悬浮提示、图表自适应宽度、窄屏单列布局
图表联动与筛选 日期区间、类别多选、支付方式选择;点击类别柱状图联动其他模块;清除联动
多维度分析 单笔消费前10名排行榜、星期与类别消费热力图、可设置预算的进度仪表盘

数据处理流程为:原始数据清洗 → 日期及类别、支付方式筛选 → 生成筛选数据集 → 应用柱状图选择的类别 → 更新指标、图表和明细。

类别柱状图始终展示顶部筛选条件下的类别对比,作为联动入口。点击其中一个类别后,其余分析模块仅展示该类别的数据。这样可以继续点击其他类别切换分析对象。修改顶部筛选条件时,程序自动清除上一次类别点击状态。

2. 进阶任务一:动态视觉效果优化

使用 CSS 关键帧定义透明度与垂直位移的变化,为指标卡添加 0.55 秒渐入效果,为图表添加 0.65 秒渐入效果。针对系统中选择减少动态效果的用户,通过媒体查询关闭动画。

使用 Plotly 的 hovertemplate 设置悬浮内容,显示日期、类别、金额或占比,避免仅靠坐标估计数值。图表使用容器宽度自适应;窗口宽度小于等于 700 像素时,多列布局转为单列,并缩小页面边距。

本次动态优化采用组件渐入与悬浮提示。数据源仍为本地 CSV,筛选操作触发页面重新计算,不设置定时刷新。

3. 进阶任务二:筛选与图表联动

日期筛选保留开始日期到结束日期之间的记录,包含两个端点。消费类别支持多选,支付方式支持"全部"或指定方式。三个条件取交集,得到基础筛选结果 base。

点击类别柱状图后,Streamlit 接收 Plotly 选择事件,回调函数读取所选点的类别,存入 st.session_state.linked。程序重新执行时,按照该类别从 base 中生成 view。指标卡、支付占比、每日趋势、热力图、预算进度、排行榜和明细统一使用 view,保证统计口径一致。

清除联动按钮把所选类别恢复为空,并更新柱状图组件键,使旧图表选择状态同步清除。顶部条件变化时执行相同处理,避免把旧类别带入新的筛选范围。

当类别选择为空或其他条件组合无数据时,页面显示提示并停止绘制;日期范围尚未选完整时,提示用户补全日期。每日趋势补齐所选区间中的日期,无消费日期显示为零。

4. 进阶任务三:多维度数据分析

消费排行榜。 通过 nlargest(10, 'amount') 选取当前范围金额最高的 10 笔记录,再使用水平条形图展示交易编号和金额,悬浮提示提供日期与类别。最高金额记录位于图表上方。

星期与类别热力图。 从消费日期提取星期编号,按消费类别和星期汇总金额,构造 5 行、7 列的矩阵。颜色越深表示累计金额越高,缺失组合补零。图中数字四舍五入到整数,悬浮提示保留两位小数。

热力图展示的是累计金额。不同星期在所选日期范围内出现次数可能不同,因此不能直接据此推断平均每日消费强度。

预算进度仪表盘。 由使用者输入当前筛选范围对应的预算,默认 40,000 元。预算使用率=当前消费金额÷当前预算×100%。未超预算时显示蓝色,超过预算后显示红色,并显示超出金额。仪表盘上限随实际使用率扩展,100% 位置保留阈值标记,预算最小为 1 元,避免除零。

5. 主要代码

以下为完整的看板主程序。代码未添加注释。

python 复制代码
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import streamlit as st
from data_utils import load_data, CATEGORIES, COLORS


def reset_link():
    st.session_state.linked = None
    st.session_state.chart_epoch = st.session_state.get('chart_epoch', 0) + 1


def select_category(key):
    points = st.session_state[key]['selection']['points']
    st.session_state.linked = points[0]['x'] if points else None


def filter_data(df, start, end, categories, payment):
    mask = df['date'].between(pd.Timestamp(start), pd.Timestamp(end))
    mask &= df['category'].isin(categories)
    if payment != '全部':
        mask &= df['payment'].eq(payment)
    return df.loc[mask].copy()


def chart(fig, height=275):
    fig.update_layout(template='plotly_white', height=height,
                      margin=dict(l=12, r=16, t=16, b=16),
                      font=dict(family='Arial, PingFang SC', size=13),
                      paper_bgcolor='white', plot_bgcolor='white',
                      xaxis_title=None, yaxis_title=None)
    return fig


def main():
    st.set_page_config(page_title='校园消费进阶看板', page_icon='📊', layout='wide')
    st.markdown('''<style>
    .stApp {background:#f4f7fb; color:#172b4d;}
    .block-container {padding-top:2rem; max-width:1500px;}
    h1 {font-size:2rem !important;}
    h3 {font-size:1.08rem !important;}
    [data-testid="stMetric"] {background:white; padding:14px 18px;
    border:1px solid #e2e8f0; border-radius:12px; animation:enter .55s ease both;}
    [data-testid="stMetricValue"] {color:#2563eb; font-size:1.7rem;}
    [data-testid="stPlotlyChart"] {animation:enter .65s ease both;}
    @keyframes enter {from {opacity:0; transform:translateY(12px);}
    to {opacity:1; transform:translateY(0);}}
    @media (prefers-reduced-motion:reduce) {
    [data-testid="stMetric"], [data-testid="stPlotlyChart"] {animation:none;}}
    @media (max-width:700px) {
    .block-container {padding:1rem;}
    [data-testid="stHorizontalBlock"] {flex-wrap:wrap;}
    [data-testid="stColumn"] {min-width:100% !important; flex:1 1 100% !important;}}
    </style>''', unsafe_allow_html=True)
    _, df = load_data()
    if 'linked' not in st.session_state:
        st.session_state.linked = None
    st.title('校园消费进阶数据看板')
    st.caption('2026年9月 · 模拟数据 / 条件筛选 / 图表联动 / 多维分析')
    f1, f2, f3 = st.columns([1.3, 1.5, 1])
    dates = f1.date_input('日期范围', value=(df['date'].min().date(), df['date'].max().date()),
                          min_value=df['date'].min().date(), max_value=df['date'].max().date(),
                          on_change=reset_link)
    categories = f2.multiselect('消费类别', CATEGORIES, default=CATEGORIES, on_change=reset_link)
    payment = f3.selectbox('支付方式', ['全部', '校园卡', '微信', '支付宝'], on_change=reset_link)
    if len(dates) != 2:
        st.info('请选择完整的开始日期和结束日期。')
        st.stop()
    base = filter_data(df, dates[0], dates[1], categories, payment)
    if base.empty:
        st.info('当前筛选条件下没有消费记录,请调整条件。')
        st.stop()
    active = st.session_state.linked
    view = base[base['category'].eq(active)].copy() if active else base.copy()
    if active:
        st.info(f'当前联动类别:{active}。指标、环形图、趋势、排行榜、热力图、预算及明细已同步更新。')
    else:
        st.caption('点击类别柱状图中的柱子可联动其他模块;类别柱状图保留筛选范围内的全局对比。')
    st.button('清除图表联动', on_click=reset_link)
    cols = st.columns(4)
    cols[0].metric('消费总额(元)', f'{view["amount"].sum():,.2f}')
    cols[1].metric('消费笔数', f'{len(view):,}')
    cols[2].metric('平均每笔(元)', f'{view["amount"].mean():.2f}' if len(view) else '0.00')
    cols[3].metric('最大单笔(元)', f'{view["amount"].max():.2f}' if len(view) else '0.00')
    color_map = dict(zip(CATEGORIES, COLORS))
    left, right = st.columns([1.2, 1])
    with left:
        st.subheader('类别对比 · 点击柱子联动')
        group = base.groupby('category', as_index=False)['amount'].sum().sort_values('amount', ascending=False)
        bar = px.bar(group, x='category', y='amount', color='category',
                     color_discrete_map=color_map, text_auto='.2f')
        bar.update_layout(showlegend=False, clickmode='event+select')
        bar.update_traces(hovertemplate='%{x}<br>金额:%{y:.2f} 元<extra></extra>')
        key = f'category_chart_{st.session_state.get("chart_epoch", 0)}'
        st.plotly_chart(chart(bar), key=key, width='stretch',
                        on_select=lambda: select_category(key), selection_mode='points')
    with right:
        st.subheader('支付方式 · 当前范围金额占比')
        payments = view.groupby('payment', as_index=False)['amount'].sum()
        pie = px.pie(payments, names='payment', values='amount', hole=.6,
                     color='payment', color_discrete_map={'校园卡':COLORS[0], '微信':COLORS[1], '支付宝':COLORS[3]})
        pie.update_traces(textinfo='label+percent', textposition='outside',
                          hovertemplate='%{label}<br>%{value:.2f} 元<br>%{percent}<extra></extra>')
        pie.update_layout(showlegend=False)
        st.plotly_chart(chart(pie), width='stretch')
    st.subheader('每日消费趋势 · 随筛选与类别点击更新')
    daily = view.groupby('date')['amount'].sum().reindex(
        pd.date_range(dates[0], dates[1]), fill_value=0).rename_axis('date').reset_index()
    line = px.line(daily, x='date', y='amount', markers=True, color_discrete_sequence=[COLORS[0]])
    line.update_traces(fill='tozeroy', fillcolor='rgba(37,99,235,.08)',
                      hovertemplate='%{x|%Y-%m-%d}<br>金额:%{y:.2f} 元<extra></extra>')
    line.update_xaxes(tickformat='%m-%d')
    st.plotly_chart(chart(line, 240), width='stretch')
    left, right = st.columns([1.35, 1])
    with left:
        st.subheader('多维分析 · 星期与消费类别热力图')
        weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
        heat_data = view.assign(weekday=view['date'].dt.dayofweek)
        heat = heat_data.pivot_table(index='category', columns='weekday', values='amount', aggfunc='sum')
        heat = heat.reindex(index=CATEGORIES, columns=range(7)).fillna(0)
        fig = px.imshow(heat.to_numpy(), x=weekdays, y=CATEGORIES,
                        color_continuous_scale='Blues', aspect='auto', text_auto='.0f')
        fig.update_traces(hovertemplate='%{y} / %{x}<br>累计金额:%{z:.2f} 元<extra></extra>')
        fig.update_layout(coloraxis_colorbar=dict(title='元'))
        st.plotly_chart(chart(fig, 280), width='stretch')
        st.caption('单元格表示当前范围内该星期与类别的累计金额;零值表示没有记录。')
    with right:
        st.subheader('预算进度 · 当前筛选范围')
        budget = st.number_input('当前范围预算(元)', min_value=1.0, value=40000.0, step=1000.0)
        ratio = view['amount'].sum() / budget * 100
        gauge = go.Figure(go.Indicator(mode='gauge+number', value=ratio,
                         number={'suffix':'%', 'valueformat':'.1f'},
                         gauge={'axis':{'range':[0, max(100, float(ratio))]},
                                'bar':{'color':'#ef4444' if ratio > 100 else '#2563eb'},
                                'steps':[{'range':[0, 100], 'color':'#e8effa'}],
                                'threshold':{'value':100, 'line':{'color':'#f59e0b', 'width':3}}}))
        st.plotly_chart(chart(gauge, 210), width='stretch')
        difference = budget - view['amount'].sum()
        st.caption(f'预算剩余 {difference:,.2f} 元' if difference >= 0 else f'已超预算 {-difference:,.2f} 元')
    st.subheader('消费排行榜 · 当前范围金额最高的10笔记录')
    top = view.nlargest(10, 'amount').sort_values('amount')
    rank = px.bar(top, x='amount', y='transaction_id', orientation='h',
                  color='category', color_discrete_map=color_map,
                  hover_data={'date':True, 'category':True}, text_auto='.2f')
    rank.update_yaxes(type='category')
    st.plotly_chart(chart(rank, 325), width='stretch')
    st.subheader('明细数据 · 当前筛选与联动结果')
    display = view.copy()
    display['date'] = display['date'].dt.strftime('%Y-%m-%d')
    display.columns = ['交易编号', '消费日期', '消费类别', '支付方式', '消费金额(元)']
    st.dataframe(display, hide_index=True, width='stretch', height=230,
                 column_config={'消费金额(元)':st.column_config.NumberColumn(format='%.2f')})
    st.caption(f'当前展示 {len(view)} 条记录;预算由使用者按当前统计范围设定。数据仅用于课程实验。')


if __name__ == '__main__':
    main()

数据读取与预处理模块 data_utils.py 如下,同样未添加注释:

python 复制代码
from pathlib import Path
import pandas as pd

ROOT = Path(__file__).resolve().parent
CATEGORIES = ['餐饮', '购物', '交通', '学习', '文娱']
COLORS = ['#2563eb', '#14b8a6', '#f59e0b', '#8b5cf6', '#f472b6']


def load_data():
    raw = pd.read_csv(ROOT / 'data' / 'campus_consumption_raw.csv')
    df = raw.copy()
    df['date'] = pd.to_datetime(df['date'], errors='coerce')
    df['amount'] = pd.to_numeric(df['amount'], errors='coerce')
    df = df.dropna(subset=['transaction_id', 'date', 'category', 'payment', 'amount'])
    df = df[df['category'].isin(CATEGORIES)]
    df = df[df['payment'].isin(['校园卡', '微信', '支付宝'])]
    df = df[df['amount'].between(0, 500, inclusive='right')]
    df = df[df['date'].between('2026-09-01', '2026-09-30')]
    df = df.drop_duplicates(subset=['transaction_id']).sort_values(['date', 'transaction_id'])
    return raw, df.reset_index(drop=True)

6. 运行方法

进入随附"校园消费进阶看板"目录,执行:

bash 复制代码
python -m pip install -r requirements.txt
python -m streamlit run app.py

浏览器打开终端提示的本地地址即可使用。工程已附带数据,如需重新生成原始数据,可执行 python generate_data.py。本次截图运行时指定端口 8502,以便与基础实验同时运行。

7. 实验运行结果

以下图片均为本次进阶程序的实际运行截图。

图1 筛选控件与数据概览。 默认选择整月、全部类别和全部支付方式,显示 900 笔有效消费。

图2 点击购物类别后的联动结果。 点击"购物"柱子后,页面提示当前联动类别,消费总额更新为 13,987.33 元,消费笔数更新为 171 笔,平均每笔为 81.80 元。类别对比图保留其他类别,便于继续切换。

图3 每日趋势、热力图与预算仪表盘。 清除联动后恢复全量数据;预算为 40,000 元时使用率约 90.3%,剩余 3,862.89 元。

图4 消费排行榜与明细表。 排行榜展示当前范围最高的 10 笔记录,最高单笔为 149.27 元,交易编号为 T0503。明细表与当前筛选、联动范围一致。

图5 窄屏自适应布局。 在 390×844 像素视口下,筛选控件和指标卡按单列排列,页面宽度无横向溢出。

8. 结果验证与问题处理

通过独立汇总、页面测试和浏览器实际点击,对以下项目进行验证:

验证项目 操作或条件 结果
默认统计 整月、全部类别、全部支付方式 900 笔,36,137.11 元,平均 40.15 元,最大单笔 149.27 元
日期、类别及支付方式组合筛选 9 月 1---15 日,餐饮,微信 81 笔,2,003.15 元,平均 24.73 元,与独立汇总一致
类别点击联动 浏览器点击购物柱子 171 笔,13,987.33 元,其他模块同步更新
清除联动 点击清除按钮 恢复 900 笔、36,137.11 元
无数据处理 清空全部消费类别 显示无记录提示,不显示错误数值或程序异常
预算计算 全量数据、预算 40,000 元 使用率 90.3%,剩余 3,862.89 元
窄屏布局 390×844 像素视口 指标卡单列排列,页面内容宽度与视口宽度均为 390 像素
动画设置 检查指标卡样式 已应用 enter 渐入动画

主要问题及处理方法如下:

问题 解决方法
图表分别筛选容易出现统计范围不一致 统一构造 base 和 view,所有关联组件使用 view
旧类别选择可能影响新的筛选条件 筛选控件变化时清除 linked,并更新图表组件键
点击一个类别后难以切换到其他类别 联动入口柱状图保留基础筛选结果,其余模块按所选类别更新
无消费日期造成趋势图时间轴不连续 重新索引到完整日期范围并补零
空类别选择使平均值出现无效结果 在绘图和指标计算前判断空数据并提示
预算与当前数据范围不一致造成误读 输入框明确标注为当前范围预算,并由使用者设定
热力图累计值容易被解释为平均水平 在图下标注累计金额,并在报告中说明日期分布差异

四、心得体会:

通过进阶实验,我进一步理解了交互式看板的数据更新过程。筛选控件和图表点击都在改变分析范围,因此需要先统一数据集,再更新各个组件。会话状态能够保存用户当前选择,而回调函数可以在页面重新计算前处理选择事件,实现图表联动。

在多维分析方面,排行榜适合定位高金额记录,热力图适合观察类别与星期的交叉分布,仪表盘适合展示目标完成比例。不同图表需要清楚的统计口径,尤其要区分累计金额与平均值,并说明预算适用的范围。

视觉优化应服务于阅读。适当的渐入动画和悬浮提示可以帮助用户理解页面变化,自适应布局能让看板在较窄的窗口中继续使用。验证时除了检查默认页面,还应覆盖组合筛选、联动清除和空数据状态,才能保证交互过程中的数值和界面保持一致。

相关推荐
databook1 小时前
让 LLM 输出不再失控:用 Pydantic 校验大模型输出
人工智能·python·llm
jingcairensheng1 小时前
vscode 配置Python虚拟环境基于扩展和原生miniconda的区别
ide·vscode·python
“AI国潮设计-小江”1 小时前
《【AIGC实战】Python+SDXL批量生成“英歌舞草莓抱抱卷蛋糕”IP插牌立牌方案,锁定角色一致性,甜品插件化落地》
开发语言·人工智能·python·prompt·aigc
LMT流氓兔1 小时前
第五单元——第7课:工具读取用户身份,而不是让模型填写身份
python·langchain
FlyWIHTSKY1 小时前
Agent阶段一:基础夯实
python·ai·fastapi
估值探索者1 小时前
【Python量化技术指标实战 04】布林带通道突破,怎么知道是真突破还是假突破?
java·开发语言·python·接口·股票数据api接口
fanruitian1 小时前
python property
开发语言·python
Web3&Basketball1 小时前
MiniMax M3.1 Flash实战:3个成本坑
python·大模型·成本优化·minimax·推理优化·token计费
Angeldream1231 小时前
第 7 章 医学影像特征提取与分析
图像处理·python·深度学习·计算机视觉·医学影像·特征提取